Files
ModelRouter/internal/lua/plugin_adapter_capability_test.go
JianFeeeee ed1e250452 test(lua): 锁死插件适配器的三条能力(实测,非推断)
为协议转换类平台(trae SOLO / gemini 信封 / codearts AK/SK 签名)装专用
适配器铺路,先把能不能做实测清楚:

① 能力:适配器**能**通过 build_headers 的 meta.api_key 拿到凭据
   (AK/SK 签名的前提),且**拿不到** __llmsproxy_secrets —— 该全局只注入
   插件 VM(plugin_bridges.go 开篇明说),安全边界完好。
   同时验 url/method/body 三个签名字符串原材料都能拿到。

② 能力:transform_stream_chunk **跨 chunk 保持状态**(5 个 chunk 拿到同一个
   Lua 状态,表地址恒定,calls 1→5)。这是 trae 跨帧累积 tool_calls、
   gemini 跨帧剥信封的前提。此前只是从池化 LIFO 复用**推断**的,现实测钉死。

③ 能力:适配器能用内核的 sha256_hex(用标准向量 sha256("abc") 校验),
   AK/SK 签名不需要外部进程。

背景:内核早已实现 __llmsproxy_adapters.install(含归属校验与冲突仲裁),
但 channels 插件对该全局的引用数是 0 —— 平台协议差异此前被当成 adapter
名差异(全塞 openai)。
2026-10-13 22:28:34 +08:00

225 lines
8.0 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 lua
import (
"fmt"
"strings"
"testing"
)
// 本文件锁死"插件装的适配器能做到哪些事"——三条能力,缺任何一条,
// 为协议转换类平台(trae SOLO / gemini 信封 / codearts AK/SK 签名)
// 装专用适配器这条路就是死的。都是**实测**得出的机制事实,不是推断。
//
// 背景:参考插件每个平台一个继承宿主 LlmAdapter 的真类,自己实现推理协议;
// 我们的内核是 proxy,平台差异此前被当成 adapter 名差异(全塞 openai)。
// 内核其实早已实现 __llmsproxy_adapters.install,只是 channels 插件没用过。
// ── 能力①:适配器能拿到凭据(AK/SK 签名必需),但拿不到保险库 ──
//
// 安全边界:__llmsproxy_secrets 只注入插件 VM(plugin_bridges.go 开篇明说
// "适配器共用 setupGlobals 而不该拿到这些能力")。适配器要凭据只能走
// build_headers 的 meta.api_key —— 那是**内核按源注入**的,范围受控。
const credentialProbeAdapter = `
local adapter = {}
adapter.name = "credprobe"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.build_headers(meta)
local ak = tostring(meta.api_key or "")
return {
["X-Probe-Has-Key"] = (ak ~= "") and "yes" or "no",
["X-Probe-Key-Len"] = tostring(#ak),
["X-Probe-Secrets-Global"] = (__llmsproxy_secrets ~= nil) and "yes" or "no",
["X-Probe-Has-Url"] = tostring(meta.url or ""),
["X-Probe-Method"] = tostring(meta.method or ""),
["X-Probe-Body-Len"] = tostring(#(meta.body or "")),
}
end
return adapter
`
const credentialProbePlugin = `
local plugin = { name = "capcred", version = "1.0" }
plugin.exports = { "install" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("credprobe", [==[` + credentialProbeAdapter + `]==])) }
end
return plugin
`
func TestAdapterCapabilityCredentialAccess(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capcred", credentialProbePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capcred", "install", nil); err != nil {
t.Fatal(err)
}
// 完整凭据 JSON(codearts 场景:AK/SK/ST 都在里面)
cred := `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`
hdrs, err := vm.BuildHeaders("credprobe", map[string]interface{}{
"url": "https://sts.example/v1/oauth2/tokens", "method": "POST",
"body": "grant_type=x", "api_key": cred,
})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
if hdrs["X-Probe-Has-Key"] != "yes" {
t.Error("适配器必须能通过 meta.api_key 拿到凭据(AK/SK 签名的前提)")
}
if hdrs["X-Probe-Key-Len"] != fmt.Sprint(len(cred)) {
t.Errorf("api_key 长度 %s ≠ %d(凭据被截断?)", hdrs["X-Probe-Key-Len"], len(cred))
}
// 安全边界:适配器**不得**能读保险库
if hdrs["X-Probe-Secrets-Global"] != "no" {
t.Error("安全回归:适配器拿到了 __llmsproxy_secrets —— " +
"该全局只应注入插件 VM")
}
// build_headers 必须能拿到 url/method/body(签名要用的签名字符串原材料)
if !strings.Contains(hdrs["X-Probe-Has-Url"], "sts.example") {
t.Error("build_headers 拿不到 url(无法构造签名)")
}
if hdrs["X-Probe-Method"] != "POST" {
t.Errorf("build_headers 拿不到 method:%q", hdrs["X-Probe-Method"])
}
if hdrs["X-Probe-Body-Len"] != "12" {
t.Errorf("build_headers 拿不到 body:len=%s", hdrs["X-Probe-Body-Len"])
}
}
// ── 能力②:流式转换跨 chunk 保持状态 ──
//
// 为什么必须测:Trae 的 SOLO 转换要跨帧累积 tool_calls,gemini 要跨帧剥
// 信封。机制上 Transform 走池化 acquire(LIFO 复用),单个流是串行调用,
// 所以**应当**拿回同一个 worker —— 但那是推断,这里实测。
const streamStateAdapter = `
local adapter = {}
adapter.name = "streambuf"
adapter.version = "1"
adapter.endpoint = "/x"
local calls = 0
local acc = {}
function adapter.transform_stream_chunk(raw)
calls = calls + 1
acc[#acc + 1] = raw
return '{"calls":' .. calls .. ',"acc":' .. #acc .. ',"vm":"' .. tostring(adapter) .. '"}'
end
return adapter
`
const streamStatePlugin = `
local plugin = { name = "capstream", version = "1.0" }
plugin.exports = { "install" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("streambuf", [==[` + streamStateAdapter + `]==])) }
end
return plugin
`
func TestAdapterCapabilityStreamStateAcrossChunks(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capstream", streamStatePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capstream", "install", nil); err != nil {
t.Fatal(err)
}
var last, firstVM string
for i := 1; i <= 5; i++ {
out, err := vm.Transform("streambuf", "transform_stream_chunk",
`{"chunk":`+fmt.Sprint(i)+`}`)
if err != nil {
t.Fatalf("chunk %d: %v", i, err)
}
last = out
if i == 1 {
firstVM = out[strings.Index(out, `"vm"`):]
}
}
if !strings.Contains(last, `"calls":5`) {
t.Fatalf("跨 chunk 状态未保持:末次 %s(期望 calls:5;若为 1 则每个 chunk "+
"都拿到全新 Lua 状态,协议转换类适配器不可行)", last)
}
if !strings.Contains(last, `"acc":5`) {
t.Fatalf("累积数组未跨 chunk 保持:%s", last)
}
// 同一个 Lua 状态(表地址恒定)—— 比计数更直接的证据
if got := last[strings.Index(last, `"vm"`):]; got != firstVM {
t.Errorf("chunk 间 Lua 状态被换了:首 %s 末 %s", firstVM, got)
}
}
// ── 能力③:适配器能自带加密原语(签名/摘要)──
//
// 内核在 setupGlobals 注册 sha256_hex / hmac_sha256_hex,适配器与插件共用,
// 所以 AK/SK 签名(SDK-HMAC-SHA256)不需要外部进程。
const cryptoProbeAdapter = `
local adapter = {}
adapter.name = "cryptoprobe"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.probe()
return {
has_sha256 = (sha256_hex ~= nil) and "yes" or "no",
has_hmac = (hmac_sha256_hex ~= nil) and "yes" or "no",
-- 固定向量:sha256("abc")
sha256_abc = sha256_hex and sha256_hex("abc") or "",
hmac_ok = hmac_sha256_hex and hmac_sha256_hex("k", "v") or "",
}
end
return adapter
`
const cryptoProbePlugin = `
local plugin = { name = "capcrypto", version = "1.0" }
plugin.exports = { "install", "probe" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("cryptoprobe", [==[` + cryptoProbeAdapter + `]==])) }
end
-- 探针:直接要求内核在该适配器上下文里跑一个函数是不可能的(Transform 只认
-- 那几个钩子),故用 transform_request 把 probe 结果带出来。
return plugin
`
func TestAdapterCapabilityCryptoPrimitives(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capcrypto", cryptoProbePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capcrypto", "install", nil); err != nil {
t.Fatal(err)
}
if !vm.HasFn("cryptoprobe", "probe") {
t.Skip("适配器未导出 probe(本断言不依赖它)")
}
// 直接用 Transform 调 probe 不行(它只认 transform_* 钩子),
// 改为验证内核确实注册了两个原语:拿一个用它们的适配器做端到端。
h, err := vm.BuildHeaders("cryptoprobe", map[string]interface{}{"api_key": "k"})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
_ = h
// 真正的断言放在下面:装一个用 sha256_hex 的适配器并跑它的钩子
if err := vm.LoadAdapterSource("hashproof", `
local adapter = {}
adapter.name = "hashproof"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
-- sha256("abc") 的标准值(用内核原语算;写死的是期望值)
return '{"got":"' .. sha256_hex("abc") .. '"}'
end
return adapter
`); err != nil {
t.Fatal(err)
}
out, err := vm.Transform("hashproof", "transform_request", "{}")
if err != nil {
t.Fatalf("transform_request: %v", err)
}
const wantSHA256abc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if !strings.Contains(out, wantSHA256abc) {
t.Fatalf("内核 sha256_hex 原语在适配器里不可用或不正确:%s", out)
}
}