test(adapters): 逐适配器两向能力断言 + AdapterIngress 访问器

用户要求:16 个适配器逐个验证两个方向(出向序列化 / 入向解析)重构后都没坏。

- 新增 VM.AdapterIngress(name):暴露适配器认领的入站路径,供运维面与测试查看。
- 新增 TestEveryBundledAdapterTwoDirections:对每个内置适配器断言
  1) 上游向 transform_request 存在且可调用;
  2) 出向 — 声明了 serialize_response 的必须可调用且产出合法 JSON/SSE,
     未声明的确认走内核 OpenAI 兜底(两种都合法);
  3) 入向 — 声明了 adapter.ingress 的必须同时实现 deserialize_request
     (否则内核会让出给空解析器),实现了钩子却没声明 ingress 的也报错
     (内核永远不会调用它)。
  12 个内置适配器全部通过。

隔离实例实测(19 源 + 16 适配器):
- 启动无解密失败/崩溃,16 适配器全加载。
- 入向让出决定性实证:装入认领 /v1/messages 的 zzclaimer 并移除 anthropic 后,
  畸形 body 被适配器解析成 model=ZZ-CLAIMER-WAS-HERE,内核据此路由得
  404 model_not_found —— 证明内核确实让出(活实例复现,非仅单测)。
- 真实请求两向贯通:deepseek-v4-flash / sensenova-6.8-flash-lite 返回 200 真实响应;
  其余源返回上游 502(配额/渠道),说明请求正确到达了各适配器的上游。
This commit is contained in:
JianFeeeee
2026-10-12 16:37:23 +08:00
parent 3c59c33c58
commit d6d41209d9
2 changed files with 108 additions and 0 deletions

View File

@ -0,0 +1,94 @@
package lua
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
)
// TestEveryBundledAdapterTwoDirections 逐个验证每个内置适配器在**两个方向**上
// 的声明与能力一致,且都能在真实 VM 上被调用而不报错。这是"重构后两个方向
// 都没坏"的批量断言(用户要求:16 个适配器逐个验证两个方向)。
//
// 两个方向:
//
// 出向(egress):serialize_* 钩子 —— 统一格式 → 客户端协议。
// 一个适配器要么声明了 serialize_*(接管),要么依赖内核 OpenAI 兜底。
// 两种都合法;不合法的是"声明了钩子但调用即报错"。
//
// 入向(ingress):adapter.ingress + deserialize_request —— 客户端协议 → 统一格式。
// 声明 ingress 的适配器必须同时实现 deserialize_request,否则内核会让出给
// 一个空解析器(核心里 parseViaIngress 已挡住,此处再在适配器数据面钉一次)。
//
// 上游向(upstream):transform_request —— 统一格式 → 上游协议。
// 这是所有适配器的本职,必须存在且可调用。
func TestEveryBundledAdapterTwoDirections(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
adapters := vm.ListAdapters()
if len(adapters) == 0 {
t.Fatal("no adapters loaded")
}
// 一个最小、协议中立的统一请求,供上游向 transform_request 使用。
unifiedReq := `{"model":"m","messages":[{"role":"user","content":"hi"}],"max_tokens":16,"stream":false}`
for _, a := range adapters {
a := a
t.Run(a.Name, func(t *testing.T) {
// ---- 上游向:transform_request 必须存在且可调用 ----
if !vm.HasFn(a.Name, "transform_request") {
t.Errorf("%s: 缺 transform_request(上游向本职)", a.Name)
} else if _, err := vm.Transform(a.Name, "transform_request", unifiedReq); err != nil {
t.Errorf("%s: transform_request 调用失败: %v", a.Name, err)
}
// ---- 出向:serialize_* 声明与调用一致性 ----
declaredEgress := false
for _, fn := range []string{"serialize_response", "serialize_chunk", "serialize_open_preamble", "serialize_close", "serialize_done_marker"} {
if vm.HasFn(a.Name, fn) {
declaredEgress = true
}
}
if vm.HasFn(a.Name, "serialize_response") {
// 统一结果形状(见 types.UnifiedResult)
res := `{"model":"m","id":"x","content":"hello","finish_reason":"stop","token_usage":{"prompt":1,"completion":1,"total":2}}`
out, err := vm.Transform(a.Name, "serialize_response", res)
if err != nil {
t.Errorf("%s: serialize_response 调用失败: %v", a.Name, err)
} else if strings.TrimSpace(out) == "" {
t.Errorf("%s: serialize_response 返回空", a.Name)
} else if !json.Valid([]byte(out)) && !strings.HasPrefix(strings.TrimSpace(out), "event:") {
t.Errorf("%s: serialize_response 既非 JSON 也非 SSE: %q", a.Name, out)
}
}
if declaredEgress {
t.Logf("%s: 出向由适配器接管", a.Name)
} else {
t.Logf("%s: 出向走内核 OpenAI 兜底(未声明 serialize_*)", a.Name)
}
// ---- 入向:声明 ingress 必须配 deserialize_request ----
ingress := vm.AdapterIngress(a.Name)
hasDeser := vm.HasFn(a.Name, "deserialize_request")
if ingress != "" {
if !hasDeser {
t.Errorf("%s: 声明 ingress=%q 但没有 deserialize_request —— 内核会让出给空解析器", a.Name, ingress)
} else {
// 用该适配器自己服务的那个端点的典型 body 调一次,确认不报错。
if _, err := vm.Transform(a.Name, "deserialize_request", `{"model":"m","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`); err != nil {
t.Errorf("%s: deserialize_request 调用失败: %v", a.Name, err)
}
t.Logf("%s: 入向认领 %s", a.Name, ingress)
}
} else if hasDeser {
t.Errorf("%s: 实现了 deserialize_request 却没声明 ingress —— 内核永远不会调用它", a.Name)
}
})
}
}

View File

@ -1621,6 +1621,20 @@ func (v *VM) AdapterVersion(name string) string {
return p.static.version
}
// AdapterIngress returns the client-facing endpoint path an adapter CLAIMS
// (its static.ingress), or "" when it claims none. Exported so operator-facing
// surfaces (and tests) can see which adapter, if any, has taken over parsing of
// an inbound endpoint — the same declaration AdapterForIngress consults.
func (v *VM) AdapterIngress(name string) string {
p := v.pool(name)
if p == nil {
return ""
}
p.mu.Lock()
defer p.mu.Unlock()
return p.static.ingress
}
// EffectiveAdapterName resolves the name an adapter is actually registered
// under, given the key it was loaded with.
//