Files
ModelRouter/internal/gateway/serializer_test.go
JianFeeeee 688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容,
已部署的机器不会因为顺序问题立刻失效。

- go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。
- `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、
  `/usr/local/bin` 目标全部改为 modelrouter。
- 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与
  `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。
- 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN /
  BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` →
  `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。
- 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退,
  避免升级到一半的 profile 被判成"core binary missing"。
- 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、
  旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。
- `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**,
  否则会部署出一个"能启动但没有 key、没有源"的空实例。
- 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。

兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留,
它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。

本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、
`adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00

368 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gateway
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"modelrouter/internal/lua"
"modelrouter/internal/types"
)
// 本文件锁定对外序列化的**优先级契约**:
//
// 适配器声明 serialize_* → 适配器完全接管
// 适配器未声明 → 兜底 OpenAI(保证刚部署/未配适配器仍能服务)
//
// 以及「让步」的定义:适配器接管后,内核绝不再往它的输出上补任何 OpenAI
// 字段。两个序列化器混在一起产出的东西比任何一个都糟——客户端无法解析,
// 而排查时会指向错误的层。
// luaVMFor 建一个会 seed 内置适配器的 VM(目录名必须是 "adapters")。
func luaVMFor(t *testing.T) *lua.VM {
t.Helper()
vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm start: %v", err)
}
t.Cleanup(vm.Stop)
return vm
}
// TestFallbackUsedWhenAdapterDeclaresNothing 是「兜底」本身的测试。
//
// 注:anthropic 现在**确实**声明了 serialize_*(Phase 2 给了它 Anthropic
// 形状),所以本测试只在仍未声明钩子的那些内置适配器上验证兜底行为。
// 这正是设计意图:谁声明谁接管,没声明的继续用 OpenAI 兜底。
func TestFallbackUsedWhenAdapterDeclaresNothing(t *testing.T) {
vm := luaVMFor(t)
for _, name := range []string{"openai", "gemini", "ollama", "deepseek", "github", "groq"} {
for _, hook := range []string{"serialize_response", "serialize_chunk"} {
if vm.HasFn(name, hook) {
t.Errorf("内置适配器 %s 声明了 %s —— 兜底测试的前提已不成立", name, hook)
}
}
}
ser := serializerFor(vm, "openai")
if _, isFallback := ser.(openAISerializer); !isFallback {
t.Fatalf("未声明钩子的适配器应得到兜底序列化器,得到 %T", ser)
}
// 对照:声明了钩子的适配器不走兜底
if _, isFallback := serializerFor(vm, "anthropic").(openAISerializer); isFallback {
t.Error("anthropic 声明了 serialize_response,不应走兜底")
}
}
// TestAdapterSerializerWinsOverFallback 锁定核心规则:适配器声明钩子即接管,
// 且内核不与兜底混合任何字段。
func TestAdapterSerializerWinsOverFallback(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil {
t.Fatal(err)
}
// 一个刻意用**非 OpenAI 形状**的适配器:如果内核混入了兜底字段
// (object/choices/usage 等),下面断言会失败。
adapter := `
local adapter = {}
adapter.name = "anthropicish"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
-- 非流式:故意用 content[] 分块 + 没有 choices/usage
function adapter.serialize_response(payload)
local p = json.decode(payload)
return json.encode({
type = "message",
role = "assistant",
model = p.model,
content = { { type = "text", text = p.content } },
stop_reason = (p.finish_reason ~= "" and p.finish_reason or "end_turn"),
})
end
-- 流式:event: style 的帧,且不带 OpenAI 的 object 字段
function adapter.serialize_chunk(payload)
local p = json.decode(payload)
return json.encode({
type = "content_block_delta",
delta = { type = "text_delta", text = p.content },
})
end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "adapters", "anthropicish.lua"), []byte(adapter), 0o644); err != nil {
t.Fatal(err)
}
vm := lua.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
defer vm.Stop()
ser := serializerFor(vm, "anthropicish")
if _, isFallback := ser.(openAISerializer); isFallback {
t.Fatal("声明了 serialize_* 的适配器必须接管序列化,而不是拿到兜底")
}
// 非流式输出必须是适配器形状,且**不含**任何 OpenAI 字段
resp := &types.UnifiedResponse{
Model: "m",
Content: "hello",
FinishReason: "stop",
TokenUsage: types.TokenUsage{Prompt: 3, Completion: 1, Total: 4},
}
body, err := ser.NonStream(resp, "m", "id-1", 1700000000)
if err != nil {
t.Fatalf("NonStream: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("adapter output not JSON: %v (%s)", err, body)
}
if got["type"] != "message" {
t.Errorf("适配器输出未被采用:type = %v, want message", got["type"])
}
// 「让步」的硬断言:OpenAI 字段一个都不许出现
for _, k := range []string{"object", "choices", "usage", "created"} {
if _, leaked := got[k]; leaked {
t.Errorf("兜底 OpenAI 字段 %q 混进了适配器的输出(让步规则被违反): %s", k, body)
}
}
// 适配器没有读 usage,但兜底也不许替它补上
if _, has := got["token_usage"]; has {
t.Errorf("兜底补了 token_usage 字段,适配器输出被污染: %s", body)
}
}
// TestAdapterSerializerStreamWinsOverFallback 同上,流式路径。
func TestAdapterSerializerStreamWinsOverFallback(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil {
t.Fatal(err)
}
adapter := `
local adapter = {}
adapter.name = "streamsh"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
function adapter.serialize_chunk(payload)
local p = json.decode(payload)
return json.encode({ type = "content_block_delta", text = p.content })
end
-- 显式声明收尾与终止标记,证明协议语义完全由适配器决定
function adapter.serialize_close(payload)
local p = json.decode(payload)
return json.encode({ type = "message_stop", stop_reason = p.finish_reason })
end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "adapters", "streamsh.lua"), []byte(adapter), 0o644); err != nil {
t.Fatal(err)
}
vm := lua.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
defer vm.Stop()
ser := serializerFor(vm, "streamsh")
if _, isFallback := ser.(openAISerializer); isFallback {
t.Fatal("声明 serialize_chunk 的适配器必须接管流式序列化")
}
st := &StreamState{}
b, err := ser.Chunk(&types.UnifiedChunk{Content: "hi"}, "m", "id", 1, st)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"content_block_delta"`) {
t.Errorf("chunk 未采用适配器形状: %s", b)
}
if strings.Contains(string(b), "chat.completion.chunk") || strings.Contains(string(b), `"choices"`) {
t.Errorf("兜底 OpenAI 帧混进了适配器输出: %s", b)
}
// 终止标记:适配器接管流式后,内核不得自作主张发 [DONE]
if m := ser.DoneMarker(); m != "" {
t.Errorf("适配器接管流式时 DoneMarker 应为空(不擅自发 [DONE]),实得 %q", m)
}
// 但它没声明 serialize_close 时,收尾仍借用兜底形状 —— 这是「没实现」
// 不是「冲突」,允许兜底。
if _, isFallbackClose := ser.(interface {
Close(model, id string, created int64, st *StreamState) ([][]byte, error)
}); !isFallbackClose {
t.Fatal("Close 不应在接口上")
}
}
// TestFallbackIsUsedWhenAdapterHookFails 验证钩子**执行失败**时的行为。
//
// 契约:退回兜底的完整输出,而不是产出「一半适配器字段 + 一半 OpenAI
// 字段」的混合体。混合体比任何一个单独结果都更难排查。
func TestFallbackIsUsedWhenAdapterHookFails(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil {
t.Fatal(err)
}
adapter := `
local adapter = {}
adapter.name = "broken"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
function adapter.serialize_response(payload)
error("deliberate failure")
end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "adapters", "broken.lua"), []byte(adapter), 0o644); err != nil {
t.Fatal(err)
}
vm := lua.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
defer vm.Stop()
ser := serializerFor(vm, "broken")
resp := &types.UnifiedResponse{Content: "fallback still works", FinishReason: "stop"}
body, err := ser.NonStream(resp, "m", "id", 1)
if err != nil {
t.Fatalf("钩子失败后必须仍能产出兜底输出,而非报错终止: %v", err)
}
if !strings.Contains(string(body), "chat.completion") {
t.Errorf("钩子失败后未退回兜底 OpenAI 形状: %s", body)
}
}
// TestFallbackSavesUnconfiguredDeployment 直接覆盖用户说的场景:
// 「刚部署没有配置任何适配器」。
func TestFallbackSavesUnconfiguredDeployment(t *testing.T) {
// 完全不建 VM(模拟适配器目录不存在/为空)
ser := serializerFor(nil, "whatever")
body, err := ser.NonStream(&types.UnifiedResponse{Content: "no adapters configured"}, "m", "id", 1)
if err != nil {
t.Fatalf("无适配器时兜底必须可用: %v", err)
}
if !strings.Contains(string(body), "chat.completion") {
t.Errorf("无适配器兜底输出形状不对: %s", body)
}
}
// TestSerializerCacheFollowsAdapterRewrite 验证插件运行时改写适配器后,
// 缓存不会继续持有旧的序列化器判定(否则新声明的钩子永远不生效,而因为
// 判定本身不报错,表现只是「适配器说好的格式没出现」,极难定位)。
func TestSerializerCacheFollowsAdapterRewrite(t *testing.T) {
vm := luaVMFor(t)
if _, isFallback := serializerForCached(vm, "openai").(openAISerializer); !isFallback {
t.Fatal("前提不成立:改写前 openai 应走兜底")
}
// 插件改写该适配器,加一个 serialize_response
src := `local a = {}
a.name = "openai"
a.version = "9"
a.endpoint = "/chat/completions"
function a.serialize_response(p) return '{"rewritten":true}' end
return a`
if err := vm.LoadAdapterSource("openai", src); err != nil {
t.Fatalf("rewrite: %v", err)
}
// 无需任何显式清缓存动作:修订号变化即让旧条目失效。
after := serializerForCached(vm, "openai")
if _, stillFallback := after.(openAISerializer); stillFallback {
t.Error("缓存未随适配器改写失效:已声明 serialize_response 却仍走兜底")
}
body, err := after.NonStream(&types.UnifiedResponse{Content: "x"}, "m", "id", 1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "rewritten") {
t.Errorf("改写后的序列化器未生效: %s", body)
}
}
// TestSerializerCacheIsPerVM 是那个 bug 的回归测试:缓存曾经只按适配器名做键,
// 于是同一进程内两个 VM 共享同一条判定——一个适配器被重写后,另一个 VM 的
// 缓存会说「没声明钩子」,新钩子永远不生效。
func TestSerializerCacheIsPerVM(t *testing.T) {
vm1 := luaVMFor(t)
vm2 := luaVMFor(t)
// 两个 VM 各自独立:改写 vm1 不得影响 vm2 的判定
if err := vm1.LoadAdapterSource("openai", `local a={} a.name="openai" a.version="9"
a.endpoint="/x" function a.serialize_response(p) return '{"only_vm1":true}' end return a`); err != nil {
t.Fatal(err)
}
if _, f := serializerForCached(vm1, "openai").(openAISerializer); f {
t.Error("vm1 改写后应改走适配器序列化")
}
if _, f := serializerForCached(vm2, "openai").(openAISerializer); !f {
t.Error("vm2 未被改写,却因共享缓存而丢失了兜底判定(跨 VM 串扰)")
}
}
// ---- 并发安全 ----
// TestSerializerForConcurrent 是压力测试:多 goroutine 并发取序列化器
// (缓存是 sync.Map,HasFn 会编译脚本 —— 这里验证并发下不 panic、不返回
// 半初始化对象,且每次结果自洽)。
func TestSerializerForConcurrent(t *testing.T) {
vm := luaVMFor(t)
const workers, iters = 16, 40
var wg sync.WaitGroup
errs := make(chan string, workers*iters)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < iters; i++ {
name := "openai"
if w%2 == 1 {
name = "anthropic"
}
ser := serializerForCached(vm, name)
if ser == nil {
errs <- "nil serializer"
return
}
// 结果必须自洽:同一适配器每次拿到同一类型,且形状与该适配器
// 声明的协议一致(openai→OpenAI,anthropic→Anthropic)。
_, isFallback := ser.(openAISerializer)
wantShape := "chat.completion"
switch name {
case "anthropic":
if isFallback {
errs <- "anthropic 并发下退回兜底"
return
}
wantShape = `"type":"message"`
default:
if !isFallback {
errs <- name + " 并发下未走兜底"
return
}
}
body, err := ser.NonStream(&types.UnifiedResponse{Content: "x"}, "m", "id", 1)
if err != nil {
errs <- "serialize: " + err.Error()
return
}
if !strings.Contains(string(body), wantShape) {
errs <- "并发下输出形状不一致: " + name + " -> " + string(body)
return
}
}
}(w)
}
wg.Wait()
close(errs)
for e := range errs {
t.Error(e)
}
}