mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
LlmAdapter 的真类(CodeArtsAdapter 自己做 SDK-HMAC-SHA256 签名);我们
此前把平台协议差异当成 adaptter 名差异(全塞 openai),签名无从表达。
内核其实早已实现 __llmsproxy_adapters.install(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 **0**。本提交第一次真正用它。
改动:
1. 插件在加载末尾装自己的 codearts 适配器(CODEARTS_ADAPTER_SRC):
build_headers 里做华为云 SDK-HMAC-SHA256 签名
(canonical = METHOD\\nURI\\nQUERY\\nCANONICAL_HEADERS\\n\\nSIGNED_HEADERS
\\nPAYLOAD_HASH;Access= 不是 Credential=;URI 补尾斜杠;
Agent-Type/X-Language 签名后追加不进 canonical)
2. codearts 平台 adapter=codearts,新增 credential_shape="full"
3. syncAccountSource 支持 credential_shape:签名平台要**完整凭据 JSON**
(适配器自己解 AK/SK),其余平台仍交裸 access token
★ 途中挖出的两个真实缺陷(都是"静默失败",没有任何报错):
① **凭据形状不匹配 → 不出源**。syncAccountSource 用 credentialOf 取
access_token,而 codearts 凭据是 {access_key_id, secret_access_key,
security_token} —— 没有 access_token,于是 tok 为空、函数静默 return。
加了 credential_shape="full" 解决。
② **add_source 失败是静默的**。内核拒绝(插件无权限、形状不符)时
syncAccountSource 不记日志,表现为"账号加了但不出源",排查无线索。
现补上失败日志(区分内核 err 与 json.ok==false)。
我自己就被 ① 绕了一圈:用 kernelTestCore 裸加载插件(没授 add_source
权限)时源一直不出,直到把内核原始返回值带出来才看到
"kernel action add_source is not granted to plugin channels"。
——判据里必须能看到**真实函数的返回值**,否则只能在黑盒里猜。
判据(全绿):
- TestCodeArtsAdapterInstalledByPlugin:适配器确实被装上、归属是 channels、
且**用 Go 标准库独立复算 HMAC** 验签(形态对但算法错时形态断言全绿,
复算才咬得住);另断言 canonical 不含 authorization/agent-type、
URI 有尾斜杠
- TestCodeArtsSourceUsesPluginAdapter:源**真的**引用 codearts 适配器
("装了但源还指向 openai"是这类改动最可能的漏配,且不报错)
回归:internal/{lua,core,provider,gateway} 全绿。
172 lines
6.5 KiB
Go
172 lines
6.5 KiB
Go
package core
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
|
||
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
|
||
// 就破了"内核零厂商名"纪律。
|
||
//
|
||
// 这组判据要证明三件事:
|
||
// 1. 插件加载时确实把适配器装上了
|
||
// 2. 装上之后,源能引用它(不是"装了但引用不到")
|
||
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
|
||
func TestCodeArtsAdapterInstalledByPlugin(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// ① 适配器必须在插件加载后就位
|
||
adapters := c.ListAdapters()
|
||
found := false
|
||
for _, a := range adapters {
|
||
if a.Name == "codearts" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
|
||
}
|
||
|
||
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
|
||
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
|
||
t.Errorf("适配器归属应为 channels,实得 %q", owner)
|
||
}
|
||
|
||
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
|
||
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
|
||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
|
||
"method": "POST",
|
||
"body": `{"model":"CodeArts-Pangu"}`,
|
||
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("签名探针: %v", err)
|
||
}
|
||
w, _ := got.(map[string]interface{})
|
||
m, _ := w["json"].(map[string]interface{})
|
||
if m == nil {
|
||
t.Fatalf("探针无 json: %#v", got)
|
||
}
|
||
if m["error"] != nil {
|
||
t.Fatalf("探针报错: %v", m["error"])
|
||
}
|
||
auth, _ := m["authorization"].(string)
|
||
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
|
||
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
|
||
}
|
||
if !strings.Contains(auth, "Signature=") {
|
||
t.Fatalf("Authorization 缺 Signature: %s", auth)
|
||
}
|
||
// 签名后追加的头必须在,且不参与 canonical
|
||
for _, k := range []string{"Agent-Type", "X-Language"} {
|
||
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
|
||
t.Errorf("缺签名后追加的头 %s", k)
|
||
}
|
||
}
|
||
if m["hdr_Agent-Type"] != "PromptCenter" {
|
||
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
|
||
}
|
||
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
|
||
canon, _ := m["canonical"].(string)
|
||
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
|
||
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
|
||
}
|
||
// Authorization 不得进 canonical
|
||
if strings.Contains(canon, "authorization") {
|
||
t.Errorf("canonical 不应含 authorization:\n%s", canon)
|
||
}
|
||
// x-sdk-content-sha256 说明 payload hash 参与了签名
|
||
if !strings.Contains(canon, "x-sdk-content-sha256") {
|
||
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
|
||
}
|
||
// URI 必须以 / 结尾(参考点名的易错点)
|
||
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
|
||
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
|
||
}
|
||
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
|
||
// 必须与适配器给出的 Signature 逐字节相同。
|
||
//
|
||
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
|
||
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
|
||
// 形态依然全对,但上游会 401。复算才是真正的判据。
|
||
sts, _ := m["string_to_sign"].(string)
|
||
if sts == "" {
|
||
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
|
||
}
|
||
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
|
||
parts := strings.SplitN(sts, "\n", 3)
|
||
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
|
||
t.Fatalf("string_to_sign 头段不符: %q", sts)
|
||
}
|
||
canonSum := sha256.Sum256([]byte(canon))
|
||
if parts[2] != hex.EncodeToString(canonSum[:]) {
|
||
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
|
||
hex.EncodeToString(canonSum[:]), parts[2])
|
||
}
|
||
mac := hmac.New(sha256.New, []byte("SKTEST"))
|
||
mac.Write([]byte(sts))
|
||
wantSig := hex.EncodeToString(mac.Sum(nil))
|
||
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
|
||
if gotSig != wantSig {
|
||
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
|
||
}
|
||
|
||
b, _ := json.Marshal(m)
|
||
t.Logf("签名探针输出: %s", string(b))
|
||
}
|
||
|
||
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
|
||
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
|
||
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
|
||
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
|
||
//("kernel action add_source is not granted to plugin channels"),
|
||
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
|
||
c, ps := poolCore(t)
|
||
// 建一个 codearts 账号 → 触发源注册
|
||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||
"platform": "codearts",
|
||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_add: %v", err)
|
||
}
|
||
w, _ := m.(map[string]interface{})
|
||
j, _ := w["json"].(map[string]interface{})
|
||
if j == nil || j["error"] != nil {
|
||
t.Fatalf("account_add 报错: %#v", m)
|
||
}
|
||
aid, _ := j["account_id"].(string)
|
||
srcs := listSourcesViaKernel(t, c)
|
||
sm, ok := srcHasAccount(srcs, aid)
|
||
if !ok {
|
||
names := []string{}
|
||
for _, x := range srcs {
|
||
if n, _ := x["name"].(string); n != "" {
|
||
names = append(names, n)
|
||
}
|
||
}
|
||
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
|
||
}
|
||
if ad, _ := sm["adapter"].(string); ad != "codearts" {
|
||
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
|
||
}
|
||
bu, _ := sm["base_url"].(string)
|
||
if !strings.Contains(bu, "snap-access") {
|
||
t.Errorf("base_url 不符: %q", bu)
|
||
}
|
||
}
|