mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。
- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
(content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
system 支持字符串与 [{type:text,text}] 两种写法)。
调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
serialize_open_preamble / serialize_chunk / serialize_close,
把统一格式译回 Anthropic Messages 形状(含 content 分块、
stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。
内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。
测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。
golden 字节级未漂移;go test -tags luajit ./... 全绿。
368 lines
13 KiB
Go
368 lines
13 KiB
Go
package gateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
"llmsproxy/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)
|
||
}
|
||
}
|