diff --git a/internal/core/channels_codearts_adapter_test.go b/internal/core/channels_codearts_adapter_test.go new file mode 100644 index 0000000..c234623 --- /dev/null +++ b/internal/core/channels_codearts_adapter_test.go @@ -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) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index d30dc90..7895b1d 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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**(不能把整个 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