mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): CodeArts 用**插件自装的专用适配器**接入(华为云 AK/SK 签名)
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
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} 全绿。
This commit is contained in:
171
internal/core/channels_codearts_adapter_test.go
Normal file
171
internal/core/channels_codearts_adapter_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe" }
|
||||
|
||||
|
||||
|
||||
@ -635,8 +635,12 @@ local PLATFORMS = {
|
||||
"snap-access.cn-north-4.myhuaweicloud.com",
|
||||
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" },
|
||||
-- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。
|
||||
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
|
||||
adapter = "openai",
|
||||
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
|
||||
-- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名
|
||||
adapter = "codearts",
|
||||
-- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名),
|
||||
-- 不是裸 access token。见 syncAccountSource 的注释。
|
||||
credential_shape = "full",
|
||||
models = { "CodeArts-Pangu" },
|
||||
checkin = {
|
||||
url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
|
||||
@ -2915,9 +2919,23 @@ local function syncAccountSource(provider, accountId)
|
||||
local plat = platformById(provider)
|
||||
local acct = poolGet(provider, accountId)
|
||||
if not plat or not acct then return end
|
||||
-- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if not tok or tok == "" then return end
|
||||
-- 凭据有两种消费方式,取决于平台的适配器:
|
||||
-- * 多数平台:适配器发 `Authorization: Bearer <access_token>`
|
||||
-- → 这里必须交出**裸 access token**(不能把整个 JSON 当 key 发上去)
|
||||
-- * 签名平台(codearts):适配器在 build_headers 里**自己从完整凭据 JSON
|
||||
-- 解 AK/SK 并签名** → 这里必须交出完整凭据 JSON,交出裸 token 反而
|
||||
-- 让适配器签不出来(真实缺陷:codearts 账号加了却不出源,因为
|
||||
-- credentialOf 找不到 access_token 而静默 return)
|
||||
-- 用 plat.credential_shape 声明,默认 "token"(保持既有平台行为不变)。
|
||||
local tok, err
|
||||
if plat.credential_shape == "full" then
|
||||
tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
err = (tok == nil or tok == "") and "无凭据" or nil
|
||||
else
|
||||
tok = credentialOf(acct.credentialRef)
|
||||
err = (tok == nil or tok == "") and "无凭据" or nil
|
||||
end
|
||||
if err then return end
|
||||
local name = accountSourceName(provider, accountId)
|
||||
local models = {}
|
||||
local disabled = disabledModelsOf(provider)
|
||||
@ -2944,8 +2962,15 @@ local function syncAccountSource(provider, accountId)
|
||||
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
|
||||
if plat.headers_extra then src.headers = plat.headers_extra end
|
||||
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
|
||||
if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr)
|
||||
else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end
|
||||
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
|
||||
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
|
||||
if aerr then
|
||||
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr))
|
||||
elseif r and r.json and r.json.ok == false then
|
||||
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error))
|
||||
else
|
||||
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)")
|
||||
end
|
||||
end
|
||||
|
||||
local function dropAccountSource(provider, accountId)
|
||||
@ -6503,6 +6528,199 @@ end
|
||||
-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。
|
||||
-- plugin.__codearts_dpop_probe:只给测试用 —— 生成一把 DPoP 密钥并签一个
|
||||
-- 真实 JWS,供判据用标准 ES256 验签(验证 DER→raw 转换与 header/payload 形状)。
|
||||
-- ============================================================================
|
||||
-- CodeArts 专用适配器(插件在加载时**自己装**)
|
||||
-- ============================================================================
|
||||
-- 为什么需要它:CodeArts 的推理端点要 **华为云 AK/SK 签名**
|
||||
-- (SDK-HMAC-SHA256),且签名必须覆盖 method/uri/query/canonical-headers/
|
||||
-- payload-hash —— 这不是"改个 endpoint 或加个静态头"能表达的,通用 openai
|
||||
-- 适配器做不了。
|
||||
--
|
||||
-- 为什么由插件装而不是做成内置适配器:CodeArts 的接入是本插件引入的能力
|
||||
-- (登录 + 凭据 + 源注册都在这里),且内核纪律是"零厂商名"——内置适配器
|
||||
-- 里出现 codearts 就破了这条线。插件自装正好把厂商知识留在插件域。
|
||||
--
|
||||
-- 与内置适配器的差别(照参考 llm-adapter.ts 的 CodeArtsAdapter):
|
||||
-- * build_headers 动态签名(凭据从 meta.api_key 解出 AK/SK/ST)
|
||||
-- * 端点是 /api/v2/chat/completions(含 /api/v2 前缀)
|
||||
-- * Agent-Type: PromptCenter / X-Language: zh-cn(**签名后**追加,
|
||||
-- 不参与 canonical —— 参考注释点名)
|
||||
local CODEARTS_ADAPTER_SRC = [==[
|
||||
local adapter = {}
|
||||
adapter.name = "codearts"
|
||||
adapter.version = "1.0.0"
|
||||
-- ⚠️ 源注册时会用平台声明的 endpoint 覆盖;这里保持一致以免两处漂移。
|
||||
adapter.endpoint = "/api/v2/chat/completions"
|
||||
|
||||
-- 华为云 SDK-HMAC-SHA256 签名。逐条对齐参考 sign.ts:signRequestHuawei:
|
||||
-- canonical = METHOD\nURI\nQUERY\nCANONICAL_HEADERS\n\nSIGNED_HEADERS\nPAYLOAD_HASH
|
||||
-- string_to_sign = "SDK-HMAC-SHA256\n" .. x-sdk-date .. "\n" .. sha256(canonical)
|
||||
-- signature = HMAC-SHA256(SK, string_to_sign)
|
||||
-- 三个易错点(参考注释都点名了):
|
||||
-- 1. URI 末尾**必须补 /**(否则签名不匹配)
|
||||
-- 2. Authorization 用 `Access=` 不是 `Credential=`
|
||||
-- 3. Agent-Type / X-Language 是**签名后追加**的,不进 canonical
|
||||
-- signed_parts:返回 (headers, canonical, string_to_sign)。
|
||||
local function signed_parts(method, url, cred, body)
|
||||
local ak = cred.access_key_id or cred.access_key or ""
|
||||
local sk = cred.secret_access_key or cred.secret_key or ""
|
||||
local st = cred.security_token or ""
|
||||
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
|
||||
local host = url:match("^https?://([^/]+)")
|
||||
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
|
||||
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
|
||||
local query = url:match("%?([^#]*)$") or ""
|
||||
local payload = body or ""
|
||||
local payload_hash = sha256_hex(payload)
|
||||
local date = os.date("!%Y%m%dT%H%M%SZ")
|
||||
|
||||
local h = {
|
||||
["host"] = host,
|
||||
["x-sdk-date"] = date,
|
||||
["x-sdk-content-sha256"] = payload_hash,
|
||||
}
|
||||
if st ~= "" then h["x-security-token"] = st end
|
||||
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
|
||||
|
||||
local keys = {}
|
||||
for k in pairs(h) do keys[#keys + 1] = k end
|
||||
table.sort(keys)
|
||||
local lines, names = {}, {}
|
||||
for _, k in ipairs(keys) do
|
||||
lines[#lines + 1] = k .. ":" .. h[k]
|
||||
names[#names + 1] = k
|
||||
end
|
||||
local signed = table.concat(names, ";")
|
||||
local canonical = table.concat({
|
||||
method:upper(), uri, query, table.concat(lines, "\n"), "",
|
||||
signed, payload_hash,
|
||||
}, "\n")
|
||||
local sts = "SDK-HMAC-SHA256\n" .. date .. "\n" .. sha256_hex(canonical)
|
||||
local sig = hmac_sha256_hex(sk, sts)
|
||||
|
||||
local out = {}
|
||||
for _, k in ipairs(keys) do
|
||||
if k ~= "host" then out[k] = h[k] end
|
||||
end
|
||||
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
|
||||
.. ",SignedHeaders=" .. signed .. ",Signature=" .. sig
|
||||
-- 签名后追加:不参与 canonical(参考注释点名)
|
||||
out["Agent-Type"] = "PromptCenter"
|
||||
out["X-Language"] = "zh-cn"
|
||||
return out, canonical, sts
|
||||
end
|
||||
|
||||
-- 兼容旧调用名(build_headers 只用 headers)
|
||||
local function signed_headers(method, url, cred, body)
|
||||
local out = signed_parts(method, url, cred, body)
|
||||
return out
|
||||
end
|
||||
|
||||
-- sign_parts(method,url,cred,body):把签名**中间值**返回给判据用。
|
||||
-- 与 build_headers 共用同一个 signed_headers(不重复实现 —— 判据必须比对
|
||||
-- 真实函数算出的 canonical / string_to_sign,自算版会掩盖真实签名被改坏)。
|
||||
function adapter.sign_parts(method, url, cred, body)
|
||||
local out, canonical, sts = signed_parts(method, url, cred, body)
|
||||
if not out then return nil, canonical end
|
||||
return out, canonical, sts
|
||||
end
|
||||
|
||||
-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。
|
||||
-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。
|
||||
function adapter.build_headers(meta)
|
||||
local h = { ["Content-Type"] = "application/json" }
|
||||
local raw = tostring(meta.api_key or "")
|
||||
local cred = nil
|
||||
if raw:sub(1, 1) == "{" then
|
||||
local ok, rec = pcall(json.decode, raw)
|
||||
if ok and type(rec) == "table" then cred = rec end
|
||||
end
|
||||
if cred == nil then
|
||||
-- 裸 token 形态(兼容旧数据):无法签名,如实说明
|
||||
h["X-Channels-Error"] = "codearts 需要完整凭据 JSON(AK/SK),当前是裸 token"
|
||||
return h
|
||||
end
|
||||
local signed, err = signed_headers(meta.method or "POST", meta.url or "", cred, meta.body)
|
||||
if not signed then
|
||||
h["X-Channels-Error"] = tostring(err)
|
||||
return h
|
||||
end
|
||||
for k, v in pairs(signed) do h[k] = v end
|
||||
return h
|
||||
end
|
||||
|
||||
-- 请求体:CodeArts 是标准 OpenAI 兼容,只需剥内核私有字段。
|
||||
function adapter.transform_request(raw_body)
|
||||
local req = __prelude.decode_body(raw_body)
|
||||
if req == nil then return raw_body end
|
||||
__prelude.strip_control(req)
|
||||
req.model = req.model or "CodeArts-Pangu"
|
||||
req.stream = req.stream or false
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
return __prelude.openai_stream_chunk(raw_chunk)
|
||||
end
|
||||
|
||||
function adapter.transform_error(status, body)
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
return adapter
|
||||
]==]
|
||||
|
||||
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
|
||||
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
|
||||
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
|
||||
local function installCodeartsAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- plugin.__codearts_adapter_sign_probe:只给测试用 —— 直接调**已装的适配器**
|
||||
-- 的 build_headers,把签名中间值(canonical / signed_headers)带出来,
|
||||
-- 让判据能比对真实函数算出的值,而不是自己重算一遍(判据与被测对象脱节
|
||||
-- 的教训:自算版会掩盖真实签名被改坏)。
|
||||
function plugin.__codearts_adapter_sign_probe(args)
|
||||
args = args or {}
|
||||
-- 从适配器池里取 build_headers 的结果不易(插件 VM 与适配器 VM 分离),
|
||||
-- 故用**同一份源码**在本 VM 里求值后调用 —— 源码是单一定义(上面那个
|
||||
-- CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。
|
||||
local f = load(CODEARTS_ADAPTER_SRC)
|
||||
if not f then return { json = { error = "适配器源码无法编译" } } end
|
||||
local adapter = f()
|
||||
local meta = {
|
||||
url = args.url, method = args.method or "POST",
|
||||
body = args.body or "", api_key = args.api_key or "",
|
||||
}
|
||||
local h = adapter.build_headers(meta)
|
||||
local out = { authorization = h["Authorization"] }
|
||||
for k, v in pairs(h) do out["hdr_" .. k] = v end
|
||||
-- 中间值必须由**真实函数**带出(判据要比对它们,不自己重算)
|
||||
local cred = nil
|
||||
local raw = tostring(meta.api_key or "")
|
||||
if raw:sub(1, 1) == "{" then
|
||||
local ok2, rec = pcall(json.decode, raw)
|
||||
if ok2 and type(rec) == "table" then cred = rec end
|
||||
end
|
||||
if cred and adapter.sign_parts then
|
||||
local _, canon, sts = adapter.sign_parts(meta.method, meta.url, cred, meta.body)
|
||||
out.canonical = canon
|
||||
out.string_to_sign = sts
|
||||
end
|
||||
return { json = out }
|
||||
end
|
||||
|
||||
-- plugin.__codearts_cred_probe:只给测试用 —— 读回最近一个 codearts 账号的
|
||||
-- 凭据,用来验证**续期材料真的落盘了**(refresh_token / code_verifier /
|
||||
-- DPoP 私钥 JWK)。只断言"登录返回成功"无法防止凭据被截断成裸 AK/SK。
|
||||
@ -6972,4 +7190,55 @@ plugin.ui = {
|
||||
},
|
||||
}
|
||||
|
||||
-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并
|
||||
-- 返回内核 add_source 的原始结果。把"静默不出源"变成可观测的结果。
|
||||
function plugin.__sync_source_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local acct = poolGet(args.platform, args.account_id)
|
||||
if not acct then return { json = { error = "账号不存在" } } end
|
||||
local raw = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
local tok = nil
|
||||
if plat.credential_shape == "full" then tok = raw else tok = credentialOf(acct.credentialRef) end
|
||||
syncAccountSource(args.platform, args.account_id)
|
||||
-- 回报同步后内核里该源是否存在
|
||||
local ls = __llmsproxy_kernel.call("list_sources", {})
|
||||
local found, ad = false, nil
|
||||
local want = accountSourceName(args.platform, args.account_id)
|
||||
local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {}
|
||||
for _, x in ipairs(arr) do
|
||||
if x.name == want then found = true ad = x.adapter end
|
||||
end
|
||||
-- 复现 add_source,把内核的原始错误带出来("静默不出源"最难查的原因
|
||||
-- 就是没人看返回值)
|
||||
local models = {}
|
||||
local disabled = disabledModelsOf(args.platform)
|
||||
for _, m in ipairs(effectiveModels(args.platform)) do
|
||||
if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end
|
||||
end
|
||||
local test_src = {
|
||||
name = want, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||||
api_key = tok, max_concurrent = 2, models = models,
|
||||
}
|
||||
if plat.endpoint then test_src.endpoint = plat.endpoint end
|
||||
local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })
|
||||
return { json = { credential_shape = plat.credential_shape or "token",
|
||||
tok_len = tok and #tok or -1,
|
||||
source_name = want, found = found, adapter = ad,
|
||||
adapter_decl = plat.adapter,
|
||||
add_ok = rr and rr.json and rr.json.ok or false,
|
||||
add_err = tostring(rerr),
|
||||
add_json = rr and json.encode(rr.json or {}) or "nil",
|
||||
models_n = #models } }
|
||||
end
|
||||
|
||||
-- 装本插件自己的专用适配器。必须放在**文件末尾**(installCodeartsAdapter 是
|
||||
-- local,只能从定义处向后可见 —— 本文件已因 local 前向引用踩过 7 次)。
|
||||
-- 放在脚本执行末尾即可:适配器装载与源注册都发生在脚本执行完之后。
|
||||
do
|
||||
local ok, err = installCodeartsAdapter()
|
||||
plugin._codearts_adapter = { ok = ok, err = err }
|
||||
end
|
||||
|
||||
return plugin
|
||||
|
||||
Reference in New Issue
Block a user