From 8b318a850759583b0d97fffe651dd9fc928ecc24 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 00:56:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(zcode):=20=E8=BA=AB=E4=BB=BD=E5=9D=97?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=20+=20=E4=BF=AE=E5=A4=8D=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=99=A8=E6=B3=84=E6=BC=8F=20+=20codearts=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20+=20=E5=A4=87=E4=BB=BD=E8=A3=81=E5=89=AA=E6=B3=9B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关 参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体 内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}: 无 system → 3012 ✗ 仅 cliPrefix(42 字符) → 3012 ✗ cliPrefix + stable(约 2898 字符)→ 200 ✓ ⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools 声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode 账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。 实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖 transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移): block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段 / block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构) 只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算) 日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构) 文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。 ★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像 全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了", 与准入毫不相干、排查完全跑偏。 判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。 另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。 ## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器 修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示 **69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。 根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的 调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。 修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload (调度器必须停在落盘之前,否则它写一个已收尾的 state)。 ★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel), 两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。 实测 internal/lua 从 600s 超时 → 62s 通过。 ## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点 codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点: ① opengw.../gateway/config → result.models(benefit) ② snap-access.../v1/model/builtin → builtinModels(常规) 而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。 已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。 判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。 ## 4. TRAE PKCE 候选来源补齐(3 → 5) 参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的 authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 —— jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。 判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。 ## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现) prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**; sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。 实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。 泛化为 prune_backups + prune_all_backups(两个前缀都裁),加空前缀守卫。 判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在 裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。 变异 2/2 咬住(只裁 adapters / 去掉守卫)。 ## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented) qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、 zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力, 必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。 清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。 --- deploy.sh | 55 ++- .../core/channels_codearts_catalog_test.go | 174 ++++++++ internal/core/channels_trae_pkce_test.go | 31 ++ internal/core/channels_upstream_gaps_test.go | 63 +++ internal/core/channels_zcode_identity_test.go | 296 +++++++++++++ internal/gateway/deploy_script_test.go | 144 +++++- internal/lua/plugin_runtime.go | 14 +- internal/lua/plugin_runtime_close_test.go | 139 ++++++ internal/lua/plugins.go | 31 +- internal/lua/plugins/channels.lua | 413 +++++++++++++++++- 10 files changed, 1320 insertions(+), 40 deletions(-) create mode 100644 internal/core/channels_codearts_catalog_test.go create mode 100644 internal/core/channels_upstream_gaps_test.go create mode 100644 internal/core/channels_zcode_identity_test.go create mode 100644 internal/lua/plugin_runtime_close_test.go diff --git a/deploy.sh b/deploy.sh index 5443a61..10429bf 100755 --- a/deploy.sh +++ b/deploy.sh @@ -309,48 +309,60 @@ verify_master_key() { return 1 } -# ---------- 适配器备份保留策略 ---------- -# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次 +# ---------- 备份目录保留策略 ---------- +# 每次部署都新建一个 .bak.<时间戳> 目录,而回滚只用到最近一次 # ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理, # 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。 # -# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则 -# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。 -# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。 -KEEP_ADAPTER_BACKUPS=5 +# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置 +# 就可能在几秒内造出成百上千个目录。 +# 只删名字严格匹配 .bak.<14位时间戳> 的目录,避免误伤人工放的目录。 +# +# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例): +# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.* +# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。 +# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入 +# prune_all_backups,否则它就是一个不会被清理的新泄漏点。 +KEEP_BACKUPS=5 -prune_adapter_backups() { +prune_backups() { + local prefix="$1" local base="/etc/llmsproxy" [[ -d "$base" ]] || return 0 + # 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录) + case "$prefix" in + adapters|plugins) ;; + *) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;; + esac # 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。 local all - mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort) - local cap=$((KEEP_ADAPTER_BACKUPS * 4)) + mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort) + local cap=$((KEEP_BACKUPS * 4)) if (( ${#all[@]} > cap )); then - warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap" + warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap" local i=0 for d in "${all[@]}"; do i=$((i + 1)) # 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。 if (( i <= ${#all[@]} - cap )); then - if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then + if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then rm -rf "${base:?}/$d" else warn "跳过名字不规范的备份目录(不删): $d" fi fi done - mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort) + mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort) fi - # 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。 + # 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。 local total=${#all[@]} i=0 for d in "${all[@]}"; do i=$((i + 1)) # i <= total-KEEP 的是较旧的,要删。 - if (( i <= total - KEEP_ADAPTER_BACKUPS )); then - if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then + if (( i <= total - KEEP_BACKUPS )); then + if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then rm -rf "${base:?}/$d" else warn "跳过名字不规范的备份目录(不删): $d" @@ -359,8 +371,15 @@ prune_adapter_backups() { done local left - left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l) - log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)" + left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l) + log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)" +} + +# prune_all_backups:两个前缀都裁。 +# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。 +prune_all_backups() { + prune_backups adapters + prune_backups plugins } # ---------- 同步适配器 ---------- @@ -380,7 +399,7 @@ sync_adapters() { cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true log " 旧适配器已备份到 $BACKUP_DIR" fi - prune_adapter_backups + prune_all_backups cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/" chmod 0644 "$TARGET_ADAPTERS/"*.lua diff --git a/internal/core/channels_codearts_catalog_test.go b/internal/core/channels_codearts_catalog_test.go new file mode 100644 index 0000000..152a2e1 --- /dev/null +++ b/internal/core/channels_codearts_catalog_test.go @@ -0,0 +1,174 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "llmsproxy/internal/lua" +) + +// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。 +// +// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的 +// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是: +// +// ① opengw gateway/config → result.models(benefit 模型) +// ② snap-access /v1/model/builtin → builtinModels(常规模型) +// +// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer +// ⇒ 这两个端点从来没有被打过。 +// +// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空" +// —— 后者只要兜底表在就恒真。 +type codeartsCatalogHarness struct { + t *testing.T + ps *lua.Plugins + mu sync.Mutex + hits map[int]int // 端点序号 → 命中次数 + gateway string + builtin string + authSeen []string +} + +func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness { + t.Helper() + h := &codeartsCatalogHarness{t: t, hits: map[int]int{}} + + mk := func(idx int, body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + h.hits[idx]++ + // ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名 + authSeen := []string{ + r.Header.Get("X-Sdk-Date"), + r.Header.Get("X-Sdk-Content-Sha256"), + r.Header.Get("Authorization"), + } + h.authSeen = append(h.authSeen, strings.Join(authSeen, "|")) + h.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + } + // ① gateway:benefit 模型在 result.models + gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`) + // ② snap builtin:常规模型在 builtinModels(**对象**,键为序号) + bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`) + t.Cleanup(gw.Close) + t.Cleanup(bi.Close) + h.gateway, h.builtin = gw.URL, bi.URL + + 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) + } + h.ps = ps + + // 把两个端点都指到假上游(顺序与平台配置一致) + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "codearts", + "models_remote": map[string]interface{}{ + "endpoint0": h.gateway, + "endpoint1": h.builtin, + }, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + // 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据") + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "codearts", + "id": "cat-test", + "credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` + + `"security_token":"STTEST","user_name":"cat"}`, + }); err != nil { + t.Fatalf("account_add: %v", err) + } + return h +} + +func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) { + h := newCodeartsCatalogHarness(t) + + if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil { + t.Fatalf("model_refresh: %v", err) + } + + h.mu.Lock() + hits := map[int]int{} + for k, v := range h.hits { + hits[k] = v + } + authSeen := append([]string(nil), h.authSeen...) + h.mu.Unlock() + + // ① 两个端点都必须被命中(只打一个是真实缺口) + if hits[0] == 0 { + t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到") + } + if hits[1] == 0 { + t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到") + } + // ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点) + for i, seen := range authSeen { + parts := strings.Split(seen, "|") + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen) + } + if !strings.Contains(parts[2], "SDK-HMAC-SHA256") { + t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2]) + } + } + + // ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认) + got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"}) + if err != nil { + t.Fatal(err) + } + ids := modelIDs(t, got) + joined := strings.Join(ids, ",") + for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} { + if !strings.Contains(joined, want) { + t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids) + } + } + t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids)) +} + +// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。 +func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) { + h := newCodeartsCatalogHarness(t) + // 让两个端点都 500 + dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer dead.Close() + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "codearts", + "models_remote": map[string]interface{}{ + "endpoint0": dead.URL, "endpoint1": dead.URL, + }, + }); err != nil { + t.Fatal(err) + } + if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil { + t.Fatalf("远端失败不该让 model_refresh 报错: %v", err) + } + got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"}) + if err != nil { + t.Fatal(err) + } + ids := modelIDs(t, got) + if len(ids) == 0 { + t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)") + } + t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids) +} diff --git a/internal/core/channels_trae_pkce_test.go b/internal/core/channels_trae_pkce_test.go index 0f02f11..2c5bba6 100644 --- a/internal/core/channels_trae_pkce_test.go +++ b/internal/core/channels_trae_pkce_test.go @@ -167,3 +167,34 @@ func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, } return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON) } + +// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串** +// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。 +// +// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链, +// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路, +// 后面的来源永远取不到。 +func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) { + for _, tc := range []struct { + name string + q map[string]string + }{ + {"code 参数", map[string]string{"code": "C1"}}, + {"authCode 参数", map[string]string{"authCode": "C2"}}, + {"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}}, + {"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}}, + {"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newTraeHarness(t) + status, body := h.beginAndPollWithCallback(tc.q) + _ = status + if n := atomic.LoadInt32(&h.exchHit); n != 0 { + t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n) + } + if !strings.Contains(body, "PKCE") { + t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body) + } + }) + } +} diff --git a/internal/core/channels_upstream_gaps_test.go b/internal/core/channels_upstream_gaps_test.go new file mode 100644 index 0000000..8f6df0d --- /dev/null +++ b/internal/core/channels_upstream_gaps_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "strings" + "testing" +) + +// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。 +// +// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮), +// 于是反复派人去"补"。判据在此把事实钉住: +// +// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm` +// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个 +// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`, +// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。 +// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token +// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。 +// +// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时, +// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。 +func TestUpstreamGapsAreDocumented(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) { + // 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH + if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") { + t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," + + "则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," + + "否则响应会被当成畸形数据") + } + // 明文端点没有信封层,故解包**不该**存在(存在反而是死代码) + if strings.Contains(s, "unwrapQoderEnvelope") { + t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" + + "要么端点已换成加密端点(那需要 wasm),要么这是死代码") + } + }) + + t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) { + if !strings.Contains(s, "本实现暂不支持该流程") { + t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " + + "参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" + + "若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)") + } + }) + + t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) { + for _, want := range []string{ + "opengw.developer.huaweicloud.com/api/v1/gateway/config", + "snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin", + `sign = "codearts"`, + } { + if !strings.Contains(s, want) { + t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+ + "**必须 AK/SK 签名**的端点,只打一个会少一半模型", want) + } + } + }) +} diff --git a/internal/core/channels_zcode_identity_test.go b/internal/core/channels_zcode_identity_test.go new file mode 100644 index 0000000..e81e055 --- /dev/null +++ b/internal/core/channels_zcode_identity_test.go @@ -0,0 +1,296 @@ +package core + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// ★ ZCode 的身份块是 3012 准入的**唯一开关**。 +// +// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接 +// +// HTTP 405 + {"code":3012,"msg":"...unusual activity."} +// +// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。 +// +// 判据矩阵(照参考,逐条锁定): +// +// 无 system → 3012 +// 仅 cliPrefix(42 字符) → 3012 +// cliPrefix + stable(约 2898 字符)→ 200 +// +// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。 +// +// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值), +// 而不是"适配器装了没"这类内部信号。 +func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) { + h := newZcodeIdentityHarness(t) + + out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`) + if err != nil { + t.Fatalf("transform_request: %v", err) + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatalf("产出不是 JSON: %v\n%s", err, out) + } + + // ① system 必须是**块数组**(官方形态),不是纯字符串 + sys, ok := req["system"].([]interface{}) + if !ok { + t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v", + req["system"], req["system"]) + } + if len(sys) < 3 { + t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys)) + } + + // ② block[0] 必须是官方 CLI 前缀(准入必需) + b0, _ := sys[0].(map[string]interface{}) + if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" { + t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got) + } + + // ③ block[1] 必须是官方 stable 段(准入必需),长度对得上 + b1, _ := sys[1].(map[string]interface{}) + stable, _ := b1["text"].(string) + if len(stable) < 2800 { + t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)", + len(stable)) + } + for _, marker := range []string{ + "You are an interactive ZCode agent", + "# Harness", + "# ZCode Desktop Context", + } { + if !strings.Contains(stable, marker) { + t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker) + } + } + + // ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台) + b2, _ := sys[2].(map[string]interface{}) + env, _ := b2["text"].(string) + if !strings.HasPrefix(env, "# Environment") { + t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))]) + } + for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} { + if !strings.Contains(env, marker) { + t.Fatalf("Environment 段缺 %q:%s", marker, env) + } + } + + // ⑤ 总长度必须达到参考声明的量级(2898) + total := len("You are ZCode, an interactive coding agent") + len(stable) + if total < 2800 { + t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total) + } + + // ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态) + msgs, _ := req["messages"].([]interface{}) + if len(msgs) == 0 { + t.Fatal("messages 为空") + } + m0, _ := msgs[0].(map[string]interface{}) + content, ok := m0["content"].([]interface{}) + if !ok { + t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T", + m0["content"]) + } + c0, _ := content[0].(map[string]interface{}) + if got, _ := c0["text"].(string); !strings.Contains(got, "") { + t.Fatalf("content[0] 必须是日期块(),实得 %q", got) + } + + t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total) +} + +// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**, +// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。 +func TestZcodeCallerSystemIsAppendedLast(t *testing.T) { + h := newZcodeIdentityHarness(t) + out, err := h.transformRequest( + `{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`) + if err != nil { + t.Fatal(err) + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatal(err) + } + sys, _ := req["system"].([]interface{}) + if len(sys) < 4 { + t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys)) + } + last, _ := sys[len(sys)-1].(map[string]interface{}) + lt, _ := last["text"].(string) + if !strings.Contains(lt, "CALLER_PROMPT_XYZ") { + t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)") + } + // 且前三块仍是官方块(身份块没有被调用方内容挤走) + b0, _ := sys[0].(map[string]interface{}) + if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" { + t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got) + } +} + +// 适配器自检出口:身份块长度与 anthropic 基座装载状态。 +// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。 +func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) { + h := newZcodeIdentityHarness(t) + res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + j := res.(map[string]interface{})["json"].(map[string]interface{}) + zc, ok := j["zcode"].(map[string]interface{}) + if !ok { + t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j) + } + if zc["ok"] != true { + t.Fatalf("zcode 适配器装载失败: %v", zc["err"]) + } +} + +// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器), +// 然后直接调那个适配器的 transform_request。 +// +// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是 +// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。 +type zcodeIdentityHarness struct { + t *testing.T + c *Core + ps *lua.Plugins +} + +func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness { + t.Helper() + 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) + } + return &zcodeIdentityHarness{t: t, c: c, ps: ps} +} + +// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。 +func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) { + h.t.Helper() + vm := h.c.VM() + if vm == nil { + h.t.Fatal("VM 不可用") + } + return vm.Transform("channels-zcode", "transform_request", body) +} + +func zcodeMin(a, b int) int { + if a < b { + return a + } + return b +} + +// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。 +// +// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖 +// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化 +// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。 +func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) { + h := newZcodeIdentityHarness(t) + + t.Run("工具定义与工具调用存活", func(t *testing.T) { + in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` + + `"tools":[{"type":"function","function":{"name":"get_weather",` + + `"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}` + out, err := h.transformRequest(in) + if err != nil { + t.Fatal(err) + } + // anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里) + if !strings.Contains(out, `"get_weather"`) { + t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out) + } + if !strings.Contains(out, `"input_schema"`) { + t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out) + } + }) + + t.Run("多模态图像块存活", func(t *testing.T) { + in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` + + `{"type":"text","text":"what"},` + + `{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}` + out, err := h.transformRequest(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) { + t.Fatalf("图像块在转换中丢失:%s", out) + } + }) + + t.Run("基座装载状态可见", func(t *testing.T) { + // 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、 + // 协议转换已退化为透传) + res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + j := res.(map[string]interface{})["json"].(map[string]interface{}) + zc := j["zcode"].(map[string]interface{}) + if zc["ok"] != true { + t.Fatalf("zcode 适配器未装载: %v", zc) + } + }) +} + +// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。 +// +// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块 +// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型 +// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。 +// +// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得), +// transform_request 必须返回错误而不是原样透传。 +func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) { + // 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到 + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir) + + 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) + } + vm := c.VM() + if vm == nil { + t.Fatal("VM 不可用") + } + // 插件自装时若基座读不到,适配器仍应装载(身份块是静态的), + // 但调用必须报错 + out, err := vm.Transform("channels-zcode", "transform_request", + `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) + if err == nil { + t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out) + } + if !strings.Contains(err.Error(), "基座") { + t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err) + } + t.Logf("✓ 基座缺失时响亮失败: %v", err) +} diff --git a/internal/gateway/deploy_script_test.go b/internal/gateway/deploy_script_test.go index 33ddf84..b3be2ea 100644 --- a/internal/gateway/deploy_script_test.go +++ b/internal/gateway/deploy_script_test.go @@ -33,13 +33,13 @@ func TestDeployPruneAdapterBackups(t *testing.T) { t.Fatalf("read deploy.sh: %v", err) } src := string(fn) - i := strings.Index(src, "prune_adapter_backups()") + i := strings.Index(src, "prune_backups()") if i < 0 { - t.Fatal("prune_adapter_backups not found in deploy.sh") + t.Fatal("prune_backups not found in deploy.sh") } j := strings.Index(src[i:], "\n}\n") if j < 0 { - t.Fatal("prune_adapter_backups has no closing brace") + t.Fatal("prune_backups has no closing brace") } body := src[i : i+j+3] @@ -47,20 +47,20 @@ func TestDeployPruneAdapterBackups(t *testing.T) { body = strings.Replace(body, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1) - // KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal + // KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal // here: hardcoding 5 in the shim overrode whatever the script configured, so - // a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The + // a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The // value is part of what is under test. - var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`) + var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`) keepM := keepRe.FindStringSubmatch(src) if keepM == nil { - t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " + + t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " + "policy would be undefined") } keep := keepM[1] - script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" + - body + "\nprune_adapter_backups\n" + script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" + + body + "\nprune_backups adapters\n" scriptPath := filepath.Join(base, "fn.sh") if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { t.Fatalf("write script: %v", err) @@ -88,7 +88,7 @@ func TestDeployPruneAdapterBackups(t *testing.T) { } if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil { - t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out) + t.Fatalf("prune_backups failed: %v\n%s", err, out) } entries, err := os.ReadDir(base) @@ -102,11 +102,11 @@ func TestDeployPruneAdapterBackups(t *testing.T) { } } - // 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE + // 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE // survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing // only three keepers made this assertion wrong in BOTH directions at first: // …09 was named as "should be deleted" while it is in fact retained. - // KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and + // KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and // irregular names are never deleted — so they consume slots rather than // being ignored. With 3 unremovable directories present, only 2 of the // well-formed ones can survive. That is the function's actual (and safe) @@ -202,3 +202,123 @@ func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) { "captures the new file and rollback restores nothing") } } + +// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。 +// +// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署 +// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。 +// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。 +// +// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀, +// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。 +func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) { + if _, err := os.Stat("../../deploy.sh"); err != nil { + t.Skip("deploy.sh not present (packaging-only checkout)") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + fn, err := os.ReadFile("../../deploy.sh") + if err != nil { + t.Fatal(err) + } + src := string(fn) + + // ① prune_all_backups 必须存在且两个前缀都裁 + i := strings.Index(src, "prune_all_backups()") + if i < 0 { + t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," + + "plugins.bak 会继续无上限堆积(实测 36 个 / 192M)") + } + j := strings.Index(src[i:], "\n}\n") + if j < 0 { + t.Fatal("prune_all_backups 没有收尾大括号") + } + body := src[i : i+j+3] + for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} { + if !strings.Contains(body, want) { + t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want) + } + } + + // ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。 + // 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如 + // runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。 + prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`) + found := map[string]bool{} + for _, m := range prefixRe.FindAllStringSubmatch(src, -1) { + found[m[1]] = true + } + if len(found) == 0 { + t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)") + } + for prefix := range found { + if !strings.Contains(body, "prune_backups "+prefix) { + t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+ + "—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix) + } + } + + // ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉 + base := t.TempDir() + k := strings.Index(src, "prune_backups()") + kj := strings.Index(src[k:], "\n}\n") + fnBody := src[k : k+kj+3] + fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1) + keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`) + km := keepRe.FindStringSubmatch(src) + if km == nil { + t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS") + } + script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + + km[1] + "\n" + fnBody + "\nprune_backups plugins\n" + sp := filepath.Join(base, "fn.sh") + if err := os.WriteFile(sp, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + for _, ts := range []string{ + "20260101120000", "20260201120000", "20260301120000", "20260401120000", + "20260501120000", "20260601120000", "20260701120000", "20260801120000", + } { + if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil { + t.Fatal(err) + } + } + // adapters 前缀**不该被动**(防止误删别的前缀) + if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil { + t.Fatalf("prune_backups plugins failed: %v\n%s", err, out) + } + entries, err := os.ReadDir(base) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range entries { + if e.IsDir() { + got[e.Name()] = true + } + } + if !got["plugins.bak.20260801120000"] { + t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始") + } + if got["plugins.bak.20260101120000"] { + t.Error("最旧的 plugins 备份存活;裁剪没生效") + } + if !got["adapters.bak.20260101120000"] { + t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围") + } + + // ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录) + bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + + km[1] + "\n" + fnBody + "\nprune_backups \"\"\n" + bp := filepath.Join(base, "bad.sh") + if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil { + t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out) + } +} diff --git a/internal/lua/plugin_runtime.go b/internal/lua/plugin_runtime.go index b8cf1f8..fd00ead 100644 --- a/internal/lua/plugin_runtime.go +++ b/internal/lua/plugin_runtime.go @@ -143,6 +143,14 @@ type PluginRuntime struct { ps *Plugins spec scheduleSpec + // stopOnce makes Stop idempotent. Stop closes a channel and waits on done; + // without this, a second Stop panics ("close of closed channel"). That is + // reachable in practice now that BOTH Unload and Close stop schedulers: + // unloading a plugin then closing the plugin set would otherwise abort the + // process during shutdown — the worst possible moment (a panic here masks + // whatever the operator was actually shutting down for). + stopOnce sync.Once + mu sync.Mutex // nextDue is when each task should next fire. nextDue map[string]time.Time @@ -457,8 +465,10 @@ func (rt *PluginRuntime) Stop() { if rt == nil { return } - close(rt.stop) - <-rt.done + rt.stopOnce.Do(func() { + close(rt.stop) + <-rt.done + }) } // callFnOn invokes a plugin function on an already-locked state. diff --git a/internal/lua/plugin_runtime_close_test.go b/internal/lua/plugin_runtime_close_test.go new file mode 100644 index 0000000..bb2f2e9 --- /dev/null +++ b/internal/lua/plugin_runtime_close_test.go @@ -0,0 +1,139 @@ +package lua + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// ★ Close() 必须停掉每个插件的**调度循环**。 +// +// 真实缺陷(与 on_unload 同一形状的第 3 个实例):`Runtime.Stop()` 此前 +// **只在单插件 Unload 里调**,Close() 不调 —— 于是「进程关闭/测试结束」 +// 这条路径上,每个插件的 PluginRuntime.loop 永不停止。 +// +// 实测:全量回归在 internal/lua 出现 **69 个 goroutine 卡在 saver/runtime +// 循环里**,最终 600s 超时。 +// +// 判据用的是**进程里真实存在的 goroutine 数**:每 LoadSource 一个插件就 +// 起一个调度循环,Close 必须让它们全部退出 —— 否则 goroutine 只增不减。 +// 这比"任务被调用过"更贴近缺陷本身(缺陷就是循环不停)。 +func TestPluginsCloseStopsSchedulerGoroutines(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + // 24 个带调度表的插件:每个起一个 PluginRuntime.loop + const n = 24 + for i := 0; i < n; i++ { + src := ` +local plugin = { name = "s` + string(rune('a'+i)) + `", version = "1.0.0", exports = {} } +function plugin.noop_task() end +plugin.schedule = { { fn = "noop_task", every = 3600 } } +return plugin +` + name := filepath.Join(pluginDir, "s"+string(rune('a'+i))+".lua") + if err := os.WriteFile(name, []byte(src), 0644); err != nil { + t.Fatal(err) + } + } + + before := runtime.NumGoroutine() + ps := NewPlugins(nil, pluginDir) + if err := ps.LoadDir(); err != nil { + t.Fatalf("LoadDir: %v", err) + } + loaded := runtime.NumGoroutine() + if loaded <= before { + t.Fatalf("加载 %d 个带调度的插件后 goroutine 没涨(%d → %d)—— 夹具失效", + n, before, loaded) + } + ps.Close() + + // ★ Close 之后循环必须**全部**退出,即回落到基线附近。 + // + // ⚠️ 这里不能用「比 loaded 小就算过」:那只要**一个** goroutine 退出就成立 + // (27 → 26 也满足),而 24 个调度循环可以全部还在跑 —— 我第一版就是 + // 这么写的,被变异测试当场揭穿(去掉 stopSchedulersAll 后仍然绿)。 + // 判据必须锚定**基线**,容差只留给无关的运行时 goroutine。 + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if runtime.NumGoroutine() <= before+2 { + break + } + time.Sleep(50 * time.Millisecond) + } + after := runtime.NumGoroutine() + if after > before+2 { + t.Fatalf("Close() 之后仍有调度循环在跑:goroutine 基线 %d、加载后 %d、Close 后 %d(加载了 %d 个插件)—— "+ + "它们会一直堆积(实测全量回归堆到 69 个卡死并 600s 超时)", before, loaded, after, n) + } + t.Logf("✓ Close() 停掉了全部调度循环(goroutine 基线 %d → 加载后 %d → Close 后 %d,%d 个插件)", + before, loaded, after, n) +} + +// ★ Stop() 必须幂等:Unload 与 Close 都会停调度器,重复调不得 panic。 +// +// 反例:Stop 里裸 `close(rt.stop)` —— 第二次调用 `panic: close of closed channel`。 +// 关闭路径上的 panic 最糟糕:它掩盖掉操作者本来在做的事(关服务/结束测试)。 +func TestPluginRuntimeStopIsIdempotent(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + src := ` +local plugin = { name = "sched2", version = "1.0.0", exports = {} } +function plugin.noop_task() end +plugin.schedule = { { fn = "noop_task", every = 30 } } +return plugin +` + if err := os.WriteFile(filepath.Join(pluginDir, "sched2.lua"), []byte(src), 0644); err != nil { + t.Fatal(err) + } + ps := NewPlugins(nil, pluginDir) + if err := ps.LoadDir(); err != nil { + t.Fatalf("LoadDir: %v", err) + } + defer ps.Close() + + ps.mu.RLock() + var rt *PluginRuntime + for _, p := range ps.plugins { + if p.Info.Name == "sched2" { + rt = p.Runtime + } + } + ps.mu.RUnlock() + if rt == nil { + t.Fatal("插件未拿到 Runtime(夹具失效)") + } + // 连调三次:第二次若 panic,本测试就会崩掉(正是要防的行为) + rt.Stop() + rt.Stop() + rt.Stop() +} + +func waitForFile(t *testing.T, path string, d time.Duration) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("等待 %s 出现超时 —— 调度任务从未运行(夹具失效)", filepath.Base(path)) +} + +func fileSize(t *testing.T, path string) int64 { + t.Helper() + st, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + return st.Size() +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 5c9d5d4..2303f67 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -572,13 +572,24 @@ func (ps *Plugins) Close() { if ps == nil { return } - // ★ 顺序至关重要:**先落盘,再给插件收尾**。 + // ★ 顺序至关重要:**先停调度,再落盘,最后给插件收尾**。 // // saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关, // 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的 // TestCloseFlushesTail 正是为守住这一点而存在)。 // // 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。 + // + // ★ 而调度器必须停在**落盘之前**:它每 tick 可能调 Lua、改状态, + // 落在 save.Close 之后就会写进一个已经收尾的 state;停在最前面也顺带 + // 保证了「收尾期间不会再有新任务被派发」。 + // + // 真实缺陷(与 on_unload 同一形状的第 3 个实例):Runtime.Stop() 此前 + // **只在单插件 Unload 里调**,Close() 不调 —— 于是每个插件的调度循环 + // (PluginRuntime.loop)在「进程关闭/测试结束」这条路径上永不停止。 + // 实测全量回归在 internal/lua 出现 69 个 goroutine 卡在 saver/runtime + // 循环里并超时 600s。 + ps.stopSchedulersAll() // Shard VMs are separate golua states; leaving them open leaks a VM per // shard on every reload. ps.shards.Shutdown() @@ -586,6 +597,24 @@ func (ps *Plugins) Close() { ps.callOnUnloadAll() } +// stopSchedulersAll 停掉每个插件的调度循环(best-effort,幂等)。 +// +// 与 Unload 里的 Runtime.Stop() 是同一契约:Close 是它在进程退出路径上的 +// 等价点,不能只有 Unload 有 —— 那正是 on_unload 缺陷的形状(“两处只修一处”)。 +func (ps *Plugins) stopSchedulersAll() { + ps.mu.Lock() + plugins := make([]*Plugin, len(ps.plugins)) + copy(plugins, ps.plugins) + ps.mu.Unlock() + for _, p := range plugins { + if p == nil || p.Runtime == nil { + continue + } + p.Runtime.Stop() + p.Runtime = nil + } +} + // callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。 // // 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了 diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 81bbb09..2d68a95 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -728,7 +728,10 @@ local PLATFORMS = { }, hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com", "snap-access.cn-north-4.myhuaweicloud.com", - "devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" }, + "devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com", + -- benefit 模型目录(照参考 OPENGW_GATEWAY_CONFIG_URL 的 host)。 + -- 不在白名单会被 HTTP 桥拒,目录只剩第 2 个端点的那一半模型。 + "opengw.developer.huaweicloud.com" }, -- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。 base_url = "https://snap-access.cn-north-4.myhuaweicloud.com", -- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名 @@ -736,7 +739,31 @@ local PLATFORMS = { -- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名), -- 不是裸 access token。见 syncAccountSource 的注释。 credential_shape = "full", - models = { "CodeArts-Pangu" }, + -- ★ 远端模型目录(照参考 fetchCodeArtsRemoteModels 的**两个**端点)。 + -- + -- 缺这个曾让 codearts 只剩硬编码的 1 个模型("CodeArts-Pangu"),而 + -- 10 个账号全在池里可用 —— 目录端点其实一直可达,只是我们没打。 + -- 参考实测的模型(GLM-5.2 / openpangu 系列 / glm-5.2-sft-harmony 等) + -- 全都由这两个端点下发: + -- ① opengw gateway/config → result.models(benefit 模型,如 glm-5.3-flash) + -- ② snap-access /v1/model/builtin → builtinModels(常规模型) + -- 两者都**必须 AK/SK 签名**(ep.sign = "codearts" 走平台签名函数), + -- 且 ② 额外要 Agent-Type / X-Language 两个不参与签名的头。 + models_remote = { + endpoints = { + { url = "https://opengw.developer.huaweicloud.com/api/v1/gateway/config", + method = "GET", sign = "codearts", parse = "codearts_gateway" }, + { url = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin", + method = "GET", sign = "codearts", parse = "codearts_builtin", + headers = { ["Content-Type"] = "application/json", + ["Agent-Type"] = "PromptCenter", + ["X-Language"] = "zh-cn" } }, + }, + auth = "none", -- 签名由 sign 分支负责,不走 Bearer + parse = "codearts_builtin", + }, + -- 兜底目录:远端不可达时仍可选中(照参考的已知可用 id)。 + models = { "CodeArts-Pangu", "deepseek-v4-flash", "GLM-5.3", "GLM-5.2" }, checkin = { url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin", method = "GET", @@ -875,7 +902,11 @@ local PLATFORMS = { parse = "zcode_configs", }, endpoint = "/api/v1/zcode-plan/anthropic/v1/messages", - adapter = "anthropic", + -- ★ 不能复用通用 anthropic:zcode 的该通道做**请求体内容检查** —— system 缺 + -- 官方身份块结构时直接 405 + {"code":3012,...}(实测;与 HTTP 头、版本号、 + -- 请求频率、tools 声明全无关)。本插件自装的 channels-zcode 复用 anthropic + -- 的协议转换,只额外注入身份块(见 ZCODE_ADAPTER_SRC)。 + adapter = "channels-zcode", -- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL) balance = { url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance", @@ -3087,6 +3118,27 @@ local function parseRemoteModels(kind, body, provider) end end + elseif kind == "codearts_builtin" then + -- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels + -- (**对象**,键为序号;用 pairs 兼容对象/数组两形态)。 + local raw = rec.builtinModels or (rec.data and rec.data.builtinModels) + if type(raw) == "table" then + for _, m in pairs(raw) do + local id = type(m) == "table" and (m.model_id or m.modelId or m.id) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "codearts_gateway" then + -- 照参考的第 1 个端点:result.models(benefit 模型)。 + local arr = rec.result and rec.result.models + if type(arr) == "table" then + for _, m in ipairs(arr) do + local id = type(m) == "table" and (m.model_id or m.modelId or m.id) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + elseif kind == "zcode_configs" then -- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象** -- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。 @@ -3165,9 +3217,36 @@ local function refreshRemoteModels(provider) local extra = plat.headers_fn(tok or "", nil) for k, v in pairs(extra or {}) do headers[k] = v end end - local resp, err = __llmsproxy_http({ - url = url, method = ep.method or "GET", headers = headers, - }) + -- ★ 签名端点:codearts(华为 snap-access)的目录**必须 AK/SK 签名**, + -- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求 + -- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头, + -- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。 + local req = { url = url, method = ep.method or "GET", headers = headers } + if ep.sign == "codearts" then + local cred + for _, a in ipairs(poolList(provider)) do + if a.enabled ~= false then + local raw = __llmsproxy_secrets.get(a.credentialRef) + local okj, cj = pcall(json.decode, raw or "") + if okj and type(cj) == "table" and (cj.access_key_id or cj.access_key) then + cred = cj break + end + end + end + if not cred then + lastErr = "无可用 AK/SK 凭据" + else + local sreq, serr = codeartsSignedRequest(req.method, url, cred, nil) + if sreq then + -- 目录端点额外要求的**非签名**头(照参考 fetchSignedGet 调用点) + for k, v in pairs(ep.headers or {}) do sreq.headers[k] = v end + req = sreq + else + lastErr = tostring(serr) + end + end + end + local resp, err = __llmsproxy_http(req) if err or not resp or resp.status ~= 200 then lastErr = (err or ("HTTP " .. tostring(resp and resp.status))) else @@ -7750,7 +7829,22 @@ traeCallback = function(plat, q, s) if account == "" then account = "trae" end -- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。 - local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code") + -- + -- ⚠️ 候选来源必须**逐个取第一个非空**(照参考 authCodeCandidates 的 5 个来源), + -- 不能用 `a or b or c` 链:jsonField 取不到时返回空串而非 nil,而 Lua 里 + -- 空串是**真值**,链会在第一个空串处短路 —— 后续来源永远取不到。 + -- authCodeInfo 可能是 JSON(含 code/authCode 字段),也可能是**纯 code 字符串** + -- (参考注释点名),故把原始值作为最后兜底。 + local authCode = "" + do + local info = q.authCodeInfo or "" + for _, cand in ipairs({ q.code or "", q.authCode or "", + jsonField(info, "code"), jsonField(info, "authCode"), + info }) do + if type(cand) == "string" and cand ~= "" then authCode = cand; break end + end + authCode = authCode:gsub("^%s+", ""):gsub("%s+$", "") + end if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then if authCode and authCode ~= "" then @@ -8908,6 +9002,289 @@ end return adapter ]==] +local ZCODE_ADAPTER_SRC = [===[ +-- ============================================================================ +-- ZCode 专用适配器(插件自装:channels-zcode) +-- +-- 为什么必须自装而不是复用通用 anthropic: +-- zcode 的 /zcode-plan/anthropic 通道对请求体做**内容检查** —— system 缺少 +-- 官方身份块结构时直接返回 +-- HTTP 405 + {"code":3012,"msg":"request has been blocked due to unusual activity."} +-- 参考实测矩阵(同账号、同验证码来源): +-- 无 system → 405 + 3012 ✗ +-- 仅 cliPrefix(42 字符) → 405 + 3012 ✗ +-- cliPrefix + stable(约 2894 字符) → 200 ✓ +-- ⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、请求频率、多轮历史、 +-- tools 声明、运行时(Electron/curl/Node)**全部无关**(三个账号 17 次实测)。 +-- +-- ⚠ 参考 2026-10-03 复测修正了两条旧说法,排查时别按旧的来: +-- ① HTTP 状态码是 **405**,不是 403; +-- ② 日期块()**不是**判据 —— 去掉照样 200。仍照发是因为 +-- 官方如此且零成本,但别把「缺它就 3012」当结论(会让排查耗在错方向)。 +-- +-- ⚠ 身份块与上游策略**强耦合**:官方客户端升级若改了结构,本文件必须同步 +-- (参考有同样的维护警告)。 +-- +-- 实现方式:复用内核的 anthropic 适配器(读它的源码并执行,拿回 adapter 表), +-- **只覆盖 transform_request** 注入身份块。协议转换(工具调用、图像块、流式帧) +-- 因此始终与 anthropic 那一份一致,不会漂移。 +-- ============================================================================ +local adapter = { name = "channels-zcode", version = "1.0.0" } + +-- ★ 官方 CLI 前缀(准入必需)。逐字复刻,勿改。 +local CLI_PREFIX = "You are ZCode, an interactive coding agent" + +-- ★ 官方 stable 段(准入必需;三段以 "\n\n" 连接)。逐字复刻,勿改。 +-- 官方完整身份块还含 ~5KB 的 dynamic 段(给 coding agent 的行为指令),与准入 +-- 无关,且它会被放在 system 开头从而**压过调用方自己的 prompt**(表现为 +-- "啰嗦、慢")—— 故只发准入必需的部分(参考同此)。 +local SECTION_A = [==[ + +You are an interactive ZCode agent that helps users with software engineering tasks. + +IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases. + +# Harness +- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal. +- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim. +- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback. +- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response. +- Reference code as `file_path:line_number` — it's clickable. +]==] +local SECTION_B = [==[ +# ZCode Desktop Context + +### Files & URLs +- Return local web URLs as Markdown links (e.g., [label](http://127.0.0.1:8080)). +- File should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace. +- Unless otherwise specified, return local file references as Markdown links (e.g., [name.md](/absolute/path/to/name.md)). + +### Inline Code Comments +- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines. +- Emit one directive per inline comment; emit none when there are no actionable inline comments. +- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file). +- Optional attributes: start, end (1-based line numbers), priority (0-3). +- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace. +- Keep line ranges tight; end defaults to start. +- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2} +]==] +local SECTION_C = [==[ +# Working style + +When you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey. Prefer reading the actual file or running the actual command over reasoning about what it probably contains. When a signal pattern-matches to a known failure, check that the evidence actually supports that specific diagnosis before acting on it. +]==] +local STABLE_JOINED = SECTION_A .. "\n\n" .. SECTION_B .. "\n\n" .. SECTION_C + +-- 参考声明的期望长度(2900)。用于自检:文本被改动(转义/截断)时立刻发现, +-- 而不是等到线上吃 3012 才知道。 +local EXPECTED_MIN_CHARS = 2800 + +-- ============================================================================ +-- 复用 anthropic 适配器 +-- ============================================================================ +-- 适配器目录 = 插件目录的**兄弟**目录(/plugins 与 /adapters)。 +-- 由插件目录推导,不猜绝对路径(测试与多实例部署都靠它隔离)。 +local function adaptersDir() + local pd = __llmsproxy_plugin_dir + if type(pd) ~= "string" or pd == "" then + pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or "" + end + if type(pd) ~= "string" or pd == "" then return nil end + local base = pd:gsub("/+$", "") + local parent = base:match("^(.*)/[^/]+$") + if not parent then return nil end + return parent .. "/adapters" +end + +-- loadAnthropic():在本地环境里执行内核的 anthropic 适配器源码,拿回它的表。 +local function loadAnthropic() + local dir = adaptersDir() + if not dir then return nil, "无法推导适配器目录" end + local f = io.open(dir .. "/anthropic.lua", "r") + if not f then return nil, "读不到 anthropic.lua(" .. dir .. ")" end + local src = f:read("*a") + f:close() + local chunk, lerr = load(src, "anthropic.lua") + if not chunk then return nil, "anthropic.lua 无法编译: " .. tostring(lerr) end + local ok, base = pcall(chunk) + if not ok or type(base) ~= "table" then + return nil, "anthropic.lua 执行失败: " .. tostring(base) + end + return base, nil +end + +local BASE, BASE_ERR = loadAnthropic() +if BASE then + for k, v in pairs(BASE) do + if adapter[k] == nil then adapter[k] = v end + end +end +adapter.name = "channels-zcode" +adapter.version = "1.0.0" + +-- ============================================================================ +-- 身份块构造 +-- ============================================================================ + +-- isGitRepo(cwd):只看 .git 是否存在(照参考,廉价无副作用)。 +local function isGitRepo(cwd) + if type(cwd) ~= "string" or cwd == "" then return false end + local p = cwd:gsub("/+$", "") .. "/.git" + local f = io.open(p, "r") + if f then f:close() return true end + -- .git 也可能是**目录**(普通仓库),io.open 打不开目录:用 popen 判存在 + local sh = io.popen and io.popen("test -e " .. p .. " && echo y") + if sh then + local r = sh:read("*a") or "" + sh:close() + if r:match("y") then return true end + end + return false +end + +-- formatLocalIsoDate():**本地时区**的 ISO 日期(官方用本地日期,不是 UTC)。 +local function formatLocalIsoDate() + local t = os.date("*t") + if not t then return "" end + return string.format("%04d-%02d-%02d", t.year, t.month, t.day) +end + +-- buildEnvironmentSection():官方形态的 "# Environment" 段。 +-- 不发它的后果:模型答不出自己是谁、不知工作目录与平台(会用相对路径瞎猜、 +-- 以为该给 bash 命令)。 +local function buildEnvironmentSection(cwd, provider, model, platform) + if type(cwd) ~= "string" or cwd:gsub("%s+", "") == "" then cwd = "." end + local plat = platform or "linux" + local shell = (plat == "win32") and "powershell" or "bash" + local arch = (os.getenv and os.getenv("LLMSPROXY_ARCH")) or "x64" + return table.concat({ + "# Environment", + "You have been invoked in the following environment:", + " - Primary working directory: " .. cwd, + " - Is a git repository: " .. (isGitRepo(cwd) and "yes" or "no"), + " - Platform: " .. plat, + " - Shell: " .. shell, + " - OS Version: " .. plat .. " " .. arch, + " - You are powered by the model named " .. tostring(provider or "zcode") .. "/" + .. tostring(model or "glm-5.3-flash") .. ".", + }, "\n") +end + +-- buildSystemBlocks(callerSystem, opts):官方形态的 system **块数组**。 +-- block[1] = cliPrefix(准入必需) +-- block[2] = stable(准入必需) +-- block[3] = Environment 段 +-- block[4] = 调用方 system(**必须追加在最后**:上游看的是前缀结构) +-- 只给**最后一块**打 cache_control:Anthropic 单请求最多 4 个断点,"逐块打" +-- 会用满预算而让 tools 再也打不了点(参考 2026-09-30 改为此形态)。 +local function buildSystemBlocks(callerSystem, opts) + opts = opts or {} + local blocks = { + { type = "text", text = CLI_PREFIX }, + { type = "text", text = STABLE_JOINED }, + { type = "text", text = buildEnvironmentSection(opts.cwd, opts.provider, opts.model, opts.platform) }, + } + if type(callerSystem) == "string" and callerSystem:match("%S") then + blocks[#blocks + 1] = { type = "text", text = callerSystem } + end + blocks[#blocks].cache_control = { type = "ephemeral" } + return blocks +end + +-- contextPrefixBlock(): 日期块(官方逐字形态)。 +-- ⚠ 照发但**不是准入判据**(见文件头)。形态细节勿"优化": +-- 整块是**一个** text 块、插到 content **数组**最前(不是拼进文本字符串, +-- 后者会改变结构、仍被判为裸请求);outro 前有 **6 个空格**缩进。 +local function contextPrefixBlock() + local body = table.concat({ + "As you answer the user's questions, you can use the following context:", + "# currentDate\nToday's date is " .. formatLocalIsoDate() .. ".", + "", + " IMPORTANT: this context may or may not be relevant to your tasks. " + .. "You should not respond to this context unless it is highly relevant to your task.", + }, "\n") + return { type = "text", text = "\n" .. body .. "\n" } +end + +-- ============================================================================ +-- 覆盖 transform_request:先做 anthropic 转换,再注入身份块 +-- ============================================================================ +function adapter.transform_request(raw_body) + -- ★ 基座读不到时**响亮失败**,不能静默退化。 + -- + -- 若在这里"尽力而为"(只注入身份块、不做协议转换),会产出一个身份块看着 + -- 完全正确、而工具调用/图像/流式结构全没转换的请求 —— 上游可能 200, + -- 但模型拿不到工具定义、图像丢失,症状是"模型变笨了",与准入毫无关系, + -- 排查会完全跑偏。宁可让请求在网关侧明确报错(可定位),也不要静默发出去。 + if type(BASE) ~= "table" or type(BASE.transform_request) ~= "function" then + return error("channels-zcode: anthropic 基座未装载(" .. tostring(BASE_ERR) .. + ")—— 协议转换无法进行,拒绝发出未转换的请求") + end + local out = raw_body + do + local ok, res = pcall(BASE.transform_request, raw_body) + if ok and type(res) == "string" then out = res end + end + if type(out) ~= "string" or out == "" then return raw_body end + + local ok, req = pcall(json.decode, out) + if not ok or type(req) ~= "table" then return out end + + -- anthropic.lua 产出的 system 是**纯字符串**;上游要的是**块数组**, + -- 故在此改写成块数组(上游检查的正是这个结构)。 + local caller = req.system + if type(caller) == "table" then + local parts = {} + for _, b in ipairs(caller) do + if type(b) == "table" and type(b.text) == "string" then parts[#parts + 1] = b.text end + end + caller = table.concat(parts, "\n\n") + end + + local opts = {} + -- cwd 必须给**真值**:参考声明「cwd is never "unknown" in real traffic」。 + opts.cwd = (os.getenv and os.getenv("PWD")) or "." + opts.provider = "zcode" + opts.model = req.model + opts.platform = "linux" + + req.system = buildSystemBlocks(caller, opts) + + -- 日期块:插到**第一条** user 消息的 content 数组最前(官方形态)。 + local msgs = req.messages + if type(msgs) == "table" then + for _, m in ipairs(msgs) do + if type(m) == "table" and m.role == "user" then + local c = m.content + if type(c) == "table" then + table.insert(c, 1, contextPrefixBlock()) + elseif type(c) == "string" then + m.content = { contextPrefixBlock(), { type = "text", text = c } } + end + break + end + end + end + + return json.encode(req) +end + +-- 暴露给判据:身份块长度与装载状态("装了但身份块为空"这类问题没有它只能靠猜) +adapter.__identity_probe = function() + return { + base_loaded = BASE ~= nil, + base_err = BASE_ERR, + cli_prefix_len = #CLI_PREFIX, + stable_len = #STABLE_JOINED, + total_len = #CLI_PREFIX + #STABLE_JOINED, + expected_min = EXPECTED_MIN_CHARS, + } +end + +return adapter + +]===] + -- ============================================================================ -- Cline 专用适配器(插件自装:channels-cline) -- ============================================================================ @@ -9066,6 +9443,24 @@ local function installGeminiAdapter() return true, nil end +-- installZcodeAdapter():装/更新本插件自己的 zcode 适配器。 +-- +-- 为什么必须有:zcode 的 /zcode-plan/anthropic 通道做**请求体内容检查** —— +-- system 缺官方身份块结构直接 405 + 3012(实测),与 HTTP 头/版本/频率全无关。 +-- 通用 anthropic 适配器不注入身份块 ⇒ 即使拿到 zcode 账号也**必然**被拒。 +-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。 +local function installZcodeAdapter() + if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + local f, lerr = load(ZCODE_ADAPTER_SRC) + if not f then return false, "源码语法错误: " .. tostring(lerr) end + local _, uerr = __llmsproxy_adapters.update("channels-zcode", ZCODE_ADAPTER_SRC) + if uerr then + local _, ierr = __llmsproxy_adapters.install("channels-zcode", ZCODE_ADAPTER_SRC) + if ierr then return false, ierr end + end + return true, nil +end + -- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。 -- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 —— -- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。 @@ -9965,6 +10360,7 @@ function plugin.__adapter_install_probe() codearts = plugin._codearts_adapter, trae = plugin._trae_adapter, gemini = plugin._gemini_adapter, + zcode = plugin._zcode_adapter, } } end @@ -10056,6 +10452,9 @@ do plugin._qoder_adapter = { ok = qok, err = qerr } local cok, cerr = installClineAdapter() plugin._cline_adapter = { ok = cok, err = cerr } + local zok, zerr = installZcodeAdapter() + plugin._zcode_adapter = { ok = zok, err = zerr } + if not zok then logEvent("ZCode 适配器装载失败: " .. tostring(zerr)) end if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end