mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6fa2a678f | |||
| b871c0c713 | |||
| 8b318a8507 | |||
| 69da3c9b92 |
55
deploy.sh
55
deploy.sh
@ -309,48 +309,60 @@ verify_master_key() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 适配器备份保留策略 ----------
|
||||
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ---------- 备份目录保留策略 ----------
|
||||
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
|
||||
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
|
||||
#
|
||||
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
|
||||
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
KEEP_ADAPTER_BACKUPS=5
|
||||
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
|
||||
# 就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 <prefix>.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
|
||||
|
||||
174
internal/core/channels_codearts_catalog_test.go
Normal file
174
internal/core/channels_codearts_catalog_test.go
Normal file
@ -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)
|
||||
}
|
||||
82
internal/core/channels_orphan_callback_test.go
Normal file
82
internal/core/channels_orphan_callback_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
|
||||
//
|
||||
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// (对应的 .json 早已消失)。它们永远不会被清,因为:
|
||||
//
|
||||
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
|
||||
// · 进程被强杀时 on_unload 没机会跑;
|
||||
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
|
||||
//
|
||||
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
|
||||
// 而不是"某函数被调用过"。
|
||||
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
|
||||
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)
|
||||
|
||||
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
|
||||
orphans := []string{
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
|
||||
keep := filepath.Join(pluginDir, ".channels-store.json")
|
||||
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 加载插件 = 走真实启动路径
|
||||
c := kernelTestCore(t)
|
||||
defer c.Close()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
|
||||
for i := 0; i < 50; i++ {
|
||||
left := 0
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
left++
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
|
||||
"(真实事故:生产攒了 18 个)", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
|
||||
}
|
||||
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
|
||||
}
|
||||
|
||||
var _ = lua.Plugins{}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
63
internal/core/channels_upstream_gaps_test.go
Normal file
63
internal/core/channels_upstream_gaps_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
285
internal/core/channels_zcode_3012_test.go
Normal file
285
internal/core/channels_zcode_3012_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
|
||||
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
|
||||
// **完全相反**(稍后重试 vs 别重试)。
|
||||
//
|
||||
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
|
||||
// (2026-10-05 自审 + 审计打出的真实缺陷)。
|
||||
//
|
||||
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
|
||||
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
|
||||
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
|
||||
func TestZcode3012ClassificationMatrix(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
// ── 真风控(必须命中)──
|
||||
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
|
||||
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
|
||||
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
|
||||
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
|
||||
|
||||
// ── 必须**不**命中(误判会让用户做错事)──
|
||||
{"13012 不是 3012", `{"code":13012}`, false},
|
||||
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
|
||||
{"标识符 trust_3012", `trust_3012`, false},
|
||||
{"字母包裹 x3012y", `x3012y`, false},
|
||||
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
|
||||
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
|
||||
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
|
||||
{"block 距 12", `temporarily blocked; see error 3012`, false},
|
||||
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
|
||||
|
||||
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
|
||||
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
|
||||
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
|
||||
{"标识符+block 紧邻", `plan-3012 blocked`, false},
|
||||
{"字母包裹+block 紧邻", `x3012 blocked`, false},
|
||||
{"下划线+block 紧邻", `trust_3012 blocked`, false},
|
||||
{"独立词+block 紧邻", `3012 blocked`, true},
|
||||
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := h.callZD("hasUnusualActivity", tc.body)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
|
||||
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
|
||||
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
|
||||
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
|
||||
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
|
||||
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
for _, tc := range []struct {
|
||||
ms int
|
||||
want string
|
||||
}{
|
||||
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
|
||||
{59000, "59.0s"}, // 不足一分钟
|
||||
{60000, "1分"}, // 整分钟
|
||||
{3600000, "1小时"}, // 整小时
|
||||
{3900000, "1小时5分"}, // 小时+分
|
||||
{500, "500ms"},
|
||||
} {
|
||||
got := h.callZDString("formatGap", tc.ms)
|
||||
if got != tc.want {
|
||||
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
|
||||
//
|
||||
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
|
||||
// 文案里」来钉死这条 —— 不是"看起来没有"。
|
||||
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
const secretID = "zcode-abcdef1234567890-SECRET"
|
||||
h.callZDAction("accountOrdinal", secretID)
|
||||
h.callZDAction2("noteRequestSent", secretID, 1000)
|
||||
h.callZDAction2("noteRequestOk", secretID, 1100)
|
||||
|
||||
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
|
||||
"accountId": secretID,
|
||||
"sentAt": 1200,
|
||||
"now": 1300,
|
||||
"status": 405,
|
||||
})
|
||||
if diag == "" {
|
||||
t.Fatal("formatDiagnostic 返回空")
|
||||
}
|
||||
// 账号 id 的每个 >=4 字符子串都不得出现
|
||||
for i := 0; i+4 <= len(secretID); i++ {
|
||||
if strings.Contains(diag, secretID[i:i+4]) {
|
||||
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diag, "账号#") {
|
||||
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
|
||||
}
|
||||
t.Logf("✓ 诊断行无凭据片段: %s", diag)
|
||||
}
|
||||
|
||||
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
|
||||
//
|
||||
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
|
||||
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
|
||||
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
|
||||
"subject": "领取 plan-x",
|
||||
"rawResponse": "request has been blocked",
|
||||
})
|
||||
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("风控文案缺 %q:%s", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
|
||||
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
|
||||
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
|
||||
t.Fatalf("空参数不应留下空壳段:%q", bare)
|
||||
}
|
||||
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
|
||||
}
|
||||
|
||||
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
|
||||
//
|
||||
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
|
||||
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
|
||||
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
|
||||
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
|
||||
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := jsonUnmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
|
||||
if shape == nil {
|
||||
t.Fatal("describeRequestShape 返回空")
|
||||
}
|
||||
chars, _ := shape["identityChars"].(float64)
|
||||
if chars < 2800 {
|
||||
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
|
||||
}
|
||||
if shape["hasDateBlock"] != true {
|
||||
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
|
||||
}
|
||||
if shape["firstRole"] != "user" {
|
||||
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
|
||||
}
|
||||
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
|
||||
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
|
||||
|
||||
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
|
||||
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
|
||||
if c, _ := empty["identityChars"].(float64); c != 0 {
|
||||
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
|
||||
//
|
||||
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
|
||||
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
|
||||
|
||||
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
v, ok := m["result"].(bool)
|
||||
if !ok {
|
||||
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if opts, ok := a.(map[string]interface{}); ok {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "opts": opts})
|
||||
} else if fn == "formatGap" {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
s, _ := m["result"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
|
||||
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if len(args) == 2 {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": args[0]})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
out, _ := m["result"].(map[string]interface{})
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
|
||||
func jsonUnmarshal(s string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(s), v)
|
||||
}
|
||||
404
internal/core/channels_zcode_identity_test.go
Normal file
404
internal/core/channels_zcode_identity_test.go
Normal file
@ -0,0 +1,404 @@
|
||||
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, "<system-reminder>") {
|
||||
t.Fatalf("content[0] 必须是日期块(<system-reminder>),实得 %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)
|
||||
}
|
||||
|
||||
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
|
||||
//
|
||||
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
|
||||
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
|
||||
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
|
||||
in := `{"model":"m","messages":[{"role":"user","content":[` +
|
||||
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
|
||||
`{"type":"text","text":"hi"}]}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
content := msgs[0].(map[string]interface{})["content"].([]interface{})
|
||||
n := 0
|
||||
for _, c := range content {
|
||||
if b, ok := c.(map[string]interface{}); ok {
|
||||
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
|
||||
//
|
||||
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
|
||||
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
|
||||
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
t.Run("首条不是 user 时不动", func(t *testing.T) {
|
||||
in := `{"model":"m","messages":[` +
|
||||
`{"role":"assistant","content":"opening"},` +
|
||||
`{"role":"user","content":"hi"}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
|
||||
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
|
||||
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
|
||||
noReminder := func(content interface{}) bool {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return !strings.Contains(c, "<system-reminder>")
|
||||
case []interface{}:
|
||||
for _, e := range c {
|
||||
if b, ok := e.(map[string]interface{}); ok {
|
||||
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 首条(assistant)不该被插日期块
|
||||
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
|
||||
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
|
||||
}
|
||||
// 后面的 user 也**不该**被插(官方不往后找)
|
||||
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
|
||||
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
|
||||
}
|
||||
// 且原文本内容必须保留(转换不能丢内容)
|
||||
b, _ := json.Marshal(msgs[0])
|
||||
if !strings.Contains(string(b), "opening") {
|
||||
t.Fatal("首条 assistant 的内容丢了")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("首条是 user 时插入", func(t *testing.T) {
|
||||
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("首条 user 的 content 应升级为块数组")
|
||||
}
|
||||
b0 := content[0].(map[string]interface{})
|
||||
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
|
||||
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
139
internal/lua/plugin_runtime_close_test.go
Normal file
139
internal/lua/plugin_runtime_close_test.go
Normal file
@ -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()
|
||||
}
|
||||
77
internal/lua/plugin_unload_on_close_test.go
Normal file
77
internal/lua/plugin_unload_on_close_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。
|
||||
//
|
||||
// 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而
|
||||
// on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上,
|
||||
// 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。
|
||||
// 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。
|
||||
//
|
||||
// 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件),
|
||||
// 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。
|
||||
func TestPluginsCloseCallsOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "unloaded.marker")
|
||||
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "leaky", version = "1.0.0", exports = {} }
|
||||
plugin.ui = nil
|
||||
function plugin.on_unload()
|
||||
local f = io.open("` + marker + `", "w")
|
||||
if f then f:write("reaped"); f:close() end
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
// 关之前不该有标记
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("on_unload 在 Close 之前就被调了?")
|
||||
}
|
||||
ps.Close()
|
||||
|
||||
// ★ 真判据:文件必须被写出来(= on_unload 真的跑过)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+
|
||||
"(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err)
|
||||
}
|
||||
t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)")
|
||||
}
|
||||
|
||||
// on_unload 抛错不得让 Close 崩掉(best-effort 契约)。
|
||||
func TestPluginsCloseSurvivesFailingOnUnload(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 = "boom", version = "1.0.0", exports = {} }
|
||||
function plugin.on_unload() error("故意炸") end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
ps.Close() // 不得 panic
|
||||
}
|
||||
@ -572,10 +572,101 @@ 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()
|
||||
ps.save.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 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
|
||||
// 一个没修 —— 这正是本次缺陷的形状(Unload 有、Close 没有)。
|
||||
func (ps *Plugins) callOnUnloadAll() {
|
||||
ps.mu.Lock()
|
||||
plugins := make([]*Plugin, len(ps.plugins))
|
||||
copy(plugins, ps.plugins)
|
||||
ps.mu.Unlock()
|
||||
for _, p := range plugins {
|
||||
ps.callOnUnload(p)
|
||||
}
|
||||
}
|
||||
|
||||
// callOnUnload 调单个插件的 on_unload(存在才调),并关掉它的 Lua state。
|
||||
//
|
||||
// 为什么关 state 也在这里:on_unload 之后 state 就没用了,而 state 一旦关掉,
|
||||
// 钩子再也调不了 —— 调用与关闭必须紧邻,否则又会漏。Unload 与 Close 各自持有
|
||||
// 的 state 生命周期不同(Unload 还要 rebuild,Close 之后整体销毁),所以这里
|
||||
// 只做「调用 + 置空」,由调用方决定其余步骤。
|
||||
func (ps *Plugins) callOnUnload(p *Plugin) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if err := ps.invokeOnUnload(p, p.Info.Name); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", p.Info.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// invokeOnUnload 真正调钩子。抽出来是为了让 Unload 与 Close 共用一份实现
|
||||
// (含 state 判空与错误返回语义),避免两处契约不一致。
|
||||
func (ps *Plugins) invokeOnUnload(p *Plugin, name string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state == nil || p.state.L == nil {
|
||||
return nil
|
||||
}
|
||||
L := p.state.L
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Pop(1)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "on_unload")
|
||||
var err error
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
err = callFnOn(L, "on_unload")
|
||||
}
|
||||
L.Pop(2) // plugin table + field remnant
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
|
||||
@ -1941,24 +2032,10 @@ func (ps *Plugins) Unload(name string) error {
|
||||
// only guaranteed point where it still can. Best-effort by design — a
|
||||
// hook that errors must not block an unload the operator explicitly asked
|
||||
// for (a wedged plugin has its own path above).
|
||||
p.mu.Lock()
|
||||
if p.state != nil && p.state.L != nil {
|
||||
p.state.L.GetGlobal(pluginGlobal)
|
||||
if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE {
|
||||
L.GetField(-1, "on_unload")
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
if err := callFnOn(L, "on_unload"); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", name, err)
|
||||
}
|
||||
}
|
||||
L.Pop(2) // adapter/plugin table + field remnant
|
||||
} else {
|
||||
L.Pop(1)
|
||||
}
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
//
|
||||
// 与 Close 共用 invokeOnUnload:两处各写一遍必然漂移 —— 本次缺陷正是
|
||||
// 「Unload 有、Close 没有」。
|
||||
_ = ps.invokeOnUnload(p, name)
|
||||
ps.rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -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", "account_adopt", "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", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "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", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
@ -2622,6 +2653,23 @@ plugin.state.log = plugin.state.log or {}
|
||||
-- 字段,先填再被 or {} 覆盖就白填了)。
|
||||
loadState()
|
||||
|
||||
-- ★ 启动清扫:删掉上一进程留下的孤儿回调文件。
|
||||
--
|
||||
-- 为什么需要它:loopbacks 追踪表只活在内存里(plugin.state.loopbacks),
|
||||
-- 进程退出后无从得知上一轮起过哪些接收器。于是进程被强杀/SIGKILL 时,
|
||||
-- 那句 on_unload 没机会跑,磁盘上就永久留下 callback-*.port / .json。
|
||||
-- 实测生产攒了 18 个孤儿 .port(对应的 .json 早已消失),且**永远不会被清** ——
|
||||
-- 重启后内存表是空的,reapLoopback 根本不知道它们存在。
|
||||
--
|
||||
-- 判据不是"文件被删了"这么弱,而是"启动后目录里不再有上一轮的残留":
|
||||
-- internal/core/channels_orphan_callback_test.go。
|
||||
do
|
||||
local dir = storeDir()
|
||||
local sh = io.popen and io.popen("rm -f " .. dir ..
|
||||
"/callback-*.port " .. dir .. "/callback-*.json 2>/dev/null")
|
||||
if sh then sh:close() end
|
||||
end
|
||||
|
||||
local function logEvent(msg)
|
||||
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
|
||||
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
|
||||
@ -3070,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 个模型的假阴性。
|
||||
@ -3148,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
|
||||
@ -6269,6 +6365,8 @@ plugin.http = {
|
||||
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
|
||||
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
|
||||
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
|
||||
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
|
||||
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
|
||||
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
|
||||
roles = { "admin" }, description = "回收服务端浏览器栈" },
|
||||
}
|
||||
@ -7495,6 +7593,24 @@ function plugin.stop_browser(req)
|
||||
end
|
||||
|
||||
-- browser_status:当前浏览器会话状态(给 UI 显示)。
|
||||
-- reap_loopbacks:回收**所有**回环接收器。
|
||||
--
|
||||
-- 为什么需要显式入口:回环本来只在**会话过期**时由 reapLoopback 收掉
|
||||
-- (sweepSessions 是惰性的,靠读取时顺带做)。于是有一条稳定的泄漏路径:
|
||||
-- 测试或运维起了 login 会话后直接退出,会话从未「过期」⇒ 接收器进程常驻。
|
||||
-- 实测本机积累到 995 个(其中 828 个超过 24 小时),全是 /tmp/Test*/ 下的
|
||||
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
|
||||
--
|
||||
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
|
||||
function plugin.reap_loopbacks(req)
|
||||
local n = 0
|
||||
for sid in pairs(plugin.state.loopbacks or {}) do
|
||||
reapLoopback(sid)
|
||||
n = n + 1
|
||||
end
|
||||
return { status = 200, json = { ok = true, reaped = n } }
|
||||
end
|
||||
|
||||
function plugin.browser_status(req)
|
||||
local b = browserStack()
|
||||
local s = b.session
|
||||
@ -7713,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
|
||||
@ -8442,11 +8573,22 @@ function claimZcode(plat, tok, args)
|
||||
else
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if ok and type(rec) == "table" then
|
||||
-- ⚠️ 业务码可能是**纯数字字符串**("3012"),tonumber 必须能认
|
||||
local code = tonumber(rec.code) or tonumber(tostring(rec.code or ""))
|
||||
-- ⚠️ 业务码可能是**纯数字字符串**("3012"):ZD 归一。
|
||||
local code = (ZD and ZD.parseBusinessCode(rec.code))
|
||||
or tonumber(rec.code) or tonumber(tostring(rec.code or ""))
|
||||
local msg = tostring(rec.msg or rec.message or "")
|
||||
if code == 3012 or msg:find("unusual activity", 1, true) then
|
||||
failedMsg = "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
|
||||
-- ★ 风控判定走**共用模块**(与推理路径同一份源码文本)
|
||||
-- 见 ZCODE_DIAG_SRC 的头注释:3012 有账号冷却惩罚,
|
||||
-- "请勿连续重试"必须同时出现在两条路径上。
|
||||
-- 判定本身:只信业务码(3012 才是风控),无码可读时才看正文语义 ——
|
||||
-- 裸数字会把 `retry after 3012 ms`(限流)误判成风控,而两者动作相反。
|
||||
local isRisk = (ZD and ZD.hasUnusualActivity(resp.body or ""))
|
||||
or (code == 3012) or (msg:find("unusual activity", 1, true) ~= nil)
|
||||
if isRisk then
|
||||
failedMsg = (ZD and ZD.formatUnusualActivityMessage({
|
||||
subject = "领取 " .. tostring(planId or ""),
|
||||
rawResponse = msg,
|
||||
})) or "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
|
||||
elseif code and code ~= 0 and code ~= 200 then
|
||||
failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code))
|
||||
else
|
||||
@ -8871,6 +9013,658 @@ end
|
||||
return adapter
|
||||
]==]
|
||||
|
||||
local ZCODE_DIAG_SRC = [===[
|
||||
-- ============================================================================
|
||||
-- ZCode 3012 判据 / 文案 / 诊断(**唯一出处**,两处消费者共用同一份源码)
|
||||
--
|
||||
-- 消费者:
|
||||
-- ① 插件主体(领取路径 claimZcode);
|
||||
-- ② 自装适配器 channels-zcode(推理路径的错误分类 + 请求体形态诊断)。
|
||||
-- 两者由 installZcodeAdapter 把本段**前置**到适配器源码里实现共用
|
||||
-- (照参考的设计:formatZcodeUnusualActivityMessage 被两条路径引用同一份,
|
||||
-- 因为 3012 不是"重试就能过",而是**有账号冷却惩罚**——"请勿连续重试"
|
||||
-- 是这段文案里最重要的一句,绝不能只在其中一条路径上有)。
|
||||
--
|
||||
-- ## 为什么不能只看裸数字(参考 2026-10-05 对抗性审计的实测结论)
|
||||
--
|
||||
-- `too many requests, retry after 3012 ms` → 仅看数字会**误判成风控**
|
||||
-- `<h1>Error 3012</h1>upstream connect timeout` → 同上
|
||||
--
|
||||
-- 前者后果**不是文案问题**:限流是"稍后重试即可"、风控是"**别重试**",
|
||||
-- 两者用户动作**完全相反**;且领取路径据此**短路**,跳过本可领取的 plan。
|
||||
--
|
||||
-- ## 为什么也不能用"独立词 3012"当门禁
|
||||
--
|
||||
-- 上游 3012 的 `msg` 原文就是 `request has been blocked due to unusual
|
||||
-- activity.` —— **不含任何数字**(数字在 `code` 字段里)。以真实文案做样本时,
|
||||
-- "必须有独立词 3012"这条门禁会**反过来把它漏掉**。
|
||||
-- ⇒ 判据以**语义短语**为主,数字只作兜底(且必须与 block 字样紧邻)。
|
||||
-- ============================================================================
|
||||
local Z = {}
|
||||
|
||||
-- isUnusualActivityCode(code):码是否为 3012。只认 number 与**纯数字字符串**。
|
||||
--
|
||||
-- ⚠ 必须认字符串形态(真实缺陷 2026-10-05):上游会回
|
||||
-- {"code":"3012","msg":"..."},而只认 number 的解析会让风控码整条丢失,
|
||||
-- 用户看到的正是那句裸上游串(漏判的直接原因)。
|
||||
-- ⚠ 只认**纯数字**(^%-?%d+$),不把任意文本静默变成业务码。
|
||||
function Z.isUnusualActivityCode(code)
|
||||
if type(code) == "number" then return code == 3012 end
|
||||
if type(code) ~= "string" then return false end
|
||||
return code:match("^%s*3012%s*$") ~= nil
|
||||
end
|
||||
|
||||
-- parseBusinessCode(value):把上游 code 归一成数字;认 number 与纯数字字符串。
|
||||
function Z.parseBusinessCode(value)
|
||||
if type(value) == "number" then
|
||||
if value ~= value or value == math.huge or value == -math.huge then return nil end
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" then return nil end
|
||||
local t = value:match("^%s*(.-)%s*$")
|
||||
if not t:match("^%-?%d+$") then return nil end
|
||||
return tonumber(t)
|
||||
end
|
||||
|
||||
-- looksLikeUnusualActivity(body):正文里以**独立词**出现 3012。
|
||||
--
|
||||
-- ⚠ 必须是词边界,不能是 body:find("3012", 1, true)(真实缺陷):裸子串会把
|
||||
-- 其它码里的数字片段一并命中,实测误判形态:
|
||||
-- {"code":13012} → 误命中
|
||||
-- plan-3012-trust → 误命中
|
||||
-- 后果不是"文案不好看":领取路径据此短路,把"plan 名里恰好含 3012"的普通
|
||||
-- 失败当成风控,**跳过本可领取的 plan**,并把真实业务码(如 1005 额度用尽)
|
||||
-- 改写成 3012 —— 两者用户动作完全相反(等额度重置 vs 别重试)。
|
||||
--
|
||||
-- ⚠ 边界集必须含 **`-` 与 `_`**:初版只排除数字 ⇒ `plan-3012-trust` 仍命中;
|
||||
-- 第二版补字母 ⇒ 仍命中(连字符不属 [0-9A-Za-z])。故取 [0-9A-Za-z_-]。
|
||||
-- `.` `,` `}` `:` 空格 行首行尾都**不**被排除,故上游真实形态
|
||||
-- {"code":3012,...} 与自然语言 `error: 3012 blocked` 照常命中。
|
||||
function Z.looksLikeUnusualActivity(body)
|
||||
if type(body) ~= "string" then return false end
|
||||
-- Lua 无 lookbehind/lookahead ⇒ 手工检查两个前导/后继字符
|
||||
local from = 1
|
||||
while true do
|
||||
local s, e = body:find("3012", from, true)
|
||||
if not s then return false end
|
||||
local before = s > 1 and body:sub(s - 1, s - 1) or ""
|
||||
local after = e < #body and body:sub(e + 1, e + 1) or ""
|
||||
local function bad(c)
|
||||
if c == "" then return false end
|
||||
return c:match("[0-9A-Za-z_%-]") ~= nil
|
||||
end
|
||||
if not bad(before) and not bad(after) then return true end
|
||||
from = s + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- mentionsUnusualActivity(body):正文是否**表达**了风控(仅凭正文时的唯一入口)。
|
||||
--
|
||||
-- ① 主判据:上游**实测原文**的语义短语(覆盖 `request has been blocked due to
|
||||
-- unusual activity.` 及其变体);
|
||||
-- ② 兜底:独立词 3012 与 block 字样**紧邻(≤10 字符)**。
|
||||
--
|
||||
-- ⚠ 为什么必须"紧邻"而不是"全文共现"(真实缺陷 N2):第一版写成"独立词 3012
|
||||
-- 且全文含 block",仍会误判限流正文:
|
||||
-- `rate limited: request blocked, retry after 3012 ms` → 距 14,误判
|
||||
-- `temporarily blocked; see error 3012` → 距 12,误判
|
||||
-- 而 `3012 blocked by waf` → 距 1,应命中
|
||||
-- 窗口 10 是实测收出来的(40 仍放过上面两个反例)。
|
||||
function Z.mentionsUnusualActivity(body)
|
||||
if type(body) ~= "string" then return false end
|
||||
if body:lower():find("unusual activity", 1, true) then return true end
|
||||
if body:lower():find("blocked due to", 1, true) then return true end
|
||||
if not Z.looksLikeUnusualActivity(body) then return false end
|
||||
-- 紧邻检查:3012 前后 10 字符内出现 block
|
||||
local low = body:lower()
|
||||
local from = 1
|
||||
while true do
|
||||
local s, e = low:find("3012", from, true)
|
||||
if not s then return false end
|
||||
local before = s > 1 and low:sub(math.max(1, s - 10), s - 1) or ""
|
||||
local after = e < #low and low:sub(e + 1, math.min(#low, e + 10)) or ""
|
||||
if before:find("block", 1, true) or after:find("block", 1, true) then return true end
|
||||
from = s + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- readBusinessCodeFromBody(body):从响应正文读业务码(认 number 与数字字符串)。
|
||||
function Z.readBusinessCodeFromBody(body)
|
||||
if type(body) ~= "string" then return nil end
|
||||
local ok, rec = pcall(json.decode, body)
|
||||
if not ok or type(rec) ~= "table" then return nil end
|
||||
return Z.parseBusinessCode(rec.code)
|
||||
end
|
||||
|
||||
-- hasUnusualActivity(body):**有优先级**——正文能解析出业务码就只信码;
|
||||
-- 无码可读才看正文语义。
|
||||
--
|
||||
-- ⚠ 优先级是必须的:{"code":3012} 这种正文**不含语义词**,直接走文本判定会
|
||||
-- 漏判 ⇒ 错误被归为可重试类 ⇒ 有**冷却惩罚**的风控被白重试多次
|
||||
-- (qoder 那边记过同型缺陷)。
|
||||
function Z.hasUnusualActivity(body)
|
||||
local code = Z.readBusinessCodeFromBody(body)
|
||||
if code ~= nil then return Z.isUnusualActivityCode(code) end
|
||||
return Z.mentionsUnusualActivity(body)
|
||||
end
|
||||
|
||||
-- formatUnusualActivityMessage(opts):3012 的**共用文案**。
|
||||
-- opts.subject(定位信息,如 planId)/ opts.diagnostic / opts.rawResponse。
|
||||
function Z.formatUnusualActivityMessage(opts)
|
||||
opts = opts or {}
|
||||
local subject = (type(opts.subject) == "string" and opts.subject ~= "")
|
||||
and (" · " .. opts.subject) or ""
|
||||
local diag = (type(opts.diagnostic) == "string" and opts.diagnostic ~= "")
|
||||
and ("本机诊断:" .. opts.diagnostic .. "\n") or ""
|
||||
local raw = (type(opts.rawResponse) == "string" and opts.rawResponse ~= "")
|
||||
and ("原始响应:" .. opts.rawResponse) or ""
|
||||
return "上游风控拦截(3012 unusual activity)" .. subject .. "。"
|
||||
.. "⚠ 该错误有账号冷却惩罚(30 分钟,反复触发会升级到 24 小时乃至停用),"
|
||||
.. "请勿连续重试。\n" .. diag .. raw
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 进程级账号画像(只给诊断用;**绝不写凭据**)
|
||||
-- ============================================================================
|
||||
-- 红线(照参考):账号只以**进程内自增序号**出现,不含 accountId/JWT/账号名
|
||||
-- 的任何字符片段。
|
||||
local profiles, nextOrdinal = {}, 1
|
||||
|
||||
local function normalizeKey(accountId)
|
||||
if accountId == nil or accountId == "" then return "-" end
|
||||
return tostring(accountId)
|
||||
end
|
||||
|
||||
-- accountOrdinal(accountId):进程内序号,**首次见到时分配**(之后稳定)。
|
||||
-- 同一账号跨请求必须是同一个序号,否则"本次是第几个账号"会随请求变化。
|
||||
function Z.accountOrdinal(accountId)
|
||||
local k = normalizeKey(accountId)
|
||||
local p = profiles[k]
|
||||
if p then return p.ordinal end
|
||||
p = { ordinal = nextOrdinal, sent = 0, ok = 0, failed = 0 }
|
||||
nextOrdinal = nextOrdinal + 1
|
||||
profiles[k] = p
|
||||
return p.ordinal
|
||||
end
|
||||
|
||||
-- noteRequestSent(accountId, sentAt):记一次"请求已发出"。
|
||||
-- ⚠ sentAt 必须在**真正发出之前**取,否则"距上一条"会把本次排队时间算进去。
|
||||
function Z.noteRequestSent(accountId, sentAt)
|
||||
Z.accountOrdinal(accountId)
|
||||
local p = profiles[normalizeKey(accountId)]
|
||||
if not p then return end
|
||||
p.sent = p.sent + 1
|
||||
-- ⚠ 旧值**下沉一格**再写新的:诊断要的"距上条"是**上一条**,不是本次。
|
||||
-- 若拿 lastSentAt 顶替,算出来恒为 0ms,这一项就永远没有信息量
|
||||
-- (参考做端到端验证时真的打印出过"距上条 0ms")。
|
||||
p.previousSentAt = p.lastSentAt
|
||||
p.lastSentAt = sentAt
|
||||
end
|
||||
|
||||
function Z.noteRequestOk(accountId, at)
|
||||
local p = profiles[normalizeKey(accountId)]
|
||||
if not p then return end
|
||||
p.ok = p.ok + 1
|
||||
p.lastOkAt = at
|
||||
end
|
||||
|
||||
function Z.noteRequestFailed(accountId)
|
||||
local p = profiles[normalizeKey(accountId)]
|
||||
if not p then return end
|
||||
p.failed = p.failed + 1
|
||||
end
|
||||
|
||||
-- formatGap(ms):把毫秒差写成一眼能读的形态。
|
||||
--
|
||||
-- ⚠ 各档**逐档单独算**:参考初版把分钟档写成 floor(seconds % 60),于是 90 秒
|
||||
-- 被渲染成"30分30秒"(分位取的是**秒的余数**)。该错误在真实探针输出里发现,
|
||||
-- 纯函数单测当时没覆盖 ≥60 秒的区间 —— 故本实现直接按 totalSeconds 拆。
|
||||
function Z.formatGap(ms)
|
||||
if ms == nil or ms < 0 then return "—" end
|
||||
if ms < 1000 then return tostring(math.floor(ms + 0.5)) .. "ms" end
|
||||
local totalSeconds = math.floor(ms / 1000)
|
||||
if totalSeconds < 60 then return string.format("%.1fs", ms / 1000) end
|
||||
local minutes = math.floor(totalSeconds / 60)
|
||||
if minutes < 60 then
|
||||
local rest = totalSeconds % 60
|
||||
if rest == 0 then return tostring(minutes) .. "分" end
|
||||
return tostring(minutes) .. "分" .. tostring(rest) .. "秒"
|
||||
end
|
||||
local hours = math.floor(minutes / 60)
|
||||
local restMin = minutes % 60
|
||||
if restMin == 0 then return tostring(hours) .. "小时" end
|
||||
return tostring(hours) .. "小时" .. tostring(restMin) .. "分"
|
||||
end
|
||||
|
||||
local function formatSinceLastOk(at, now)
|
||||
if at == nil then return "从未成功过" end
|
||||
return Z.formatGap(now - at) .. "前"
|
||||
end
|
||||
|
||||
-- describeRequestShape(system, messages):读**真正要发出去的那个对象**的形态。
|
||||
--
|
||||
-- ⚠ 刻意**不读**常量(如"官方身份块 2898 字符"):常量只能证明"源码里写了多少
|
||||
-- 字",证明不了"线上发出去多少字"。两者一旦不一致(例如某条改动把 system
|
||||
-- 顶掉了),只有读实际值才看得出来 —— 它会**如实显示 0 字符**。
|
||||
function Z.describeRequestShape(system, messages)
|
||||
local blocks = type(system) == "table" and system or {}
|
||||
local function blockChars(b)
|
||||
if type(b) ~= "table" then return 0 end
|
||||
return type(b.text) == "string" and #b.text or 0
|
||||
end
|
||||
local n = #blocks
|
||||
local identityChars = 0
|
||||
local perBlock = {}
|
||||
for i = 1, math.min(2, n) do
|
||||
local c = blockChars(blocks[i])
|
||||
perBlock[#perBlock + 1] = c
|
||||
identityChars = identityChars + c
|
||||
end
|
||||
local first = type(messages) == "table" and messages[1] or nil
|
||||
local hasDate = false
|
||||
if type(first) == "table" then
|
||||
local c = first.content
|
||||
if type(c) == "table" and type(c[1]) == "table" and type(c[1].text) == "string" then
|
||||
hasDate = c[1].text:sub(1, 17) == "<system-reminder>"
|
||||
elseif type(c) == "string" then
|
||||
hasDate = c:sub(1, 17) == "<system-reminder>"
|
||||
end
|
||||
end
|
||||
return {
|
||||
systemBlocks = n,
|
||||
identityChars = identityChars,
|
||||
identityBlockChars = perBlock,
|
||||
cliPrefixChars = n >= 1 and blockChars(blocks[1]) or 0,
|
||||
hasDateBlock = hasDate,
|
||||
firstRole = (type(first) == "table" and tostring(first.role)) or "",
|
||||
}
|
||||
end
|
||||
|
||||
-- formatDiagnostic(input):拼一行现场数据(**不含任何凭据片段**)。
|
||||
function Z.formatDiagnostic(input)
|
||||
input = input or {}
|
||||
local p = profiles[normalizeKey(input.accountId)]
|
||||
local ordinal = (p and p.ordinal) or Z.accountOrdinal(input.accountId)
|
||||
local gap = (p and p.previousSentAt ~= nil)
|
||||
and Z.formatGap((input.sentAt or 0) - p.previousSentAt) or "—"
|
||||
local parts = {
|
||||
"账号#" .. tostring(ordinal),
|
||||
"本进程 成功 " .. tostring(p and p.ok or 0) .. "/失败 " .. tostring(p and p.failed or 0),
|
||||
"最近成功 " .. formatSinceLastOk(p and p.lastOkAt, input.now or 0),
|
||||
"距上条 " .. gap,
|
||||
}
|
||||
local shape = input.shape
|
||||
if shape then
|
||||
-- ⚠ 用 ~= nil 而非真值判断:Lua 里表恒为真,真正的分支是"传了/没传"
|
||||
local per = #(shape.identityBlockChars or {}) > 0
|
||||
and table.concat(shape.identityBlockChars, "+") or "无"
|
||||
parts[#parts + 1] = "身份块 " .. tostring(shape.identityChars or 0)
|
||||
.. " 字符(" .. per .. ")"
|
||||
parts[#parts + 1] = "日期块 " .. (shape.hasDateBlock and "有" or "无")
|
||||
end
|
||||
parts[#parts + 1] = "HTTP " .. tostring(input.status)
|
||||
return table.concat(parts, " · ")
|
||||
end
|
||||
|
||||
-- resetDiagnostics():清空全部进程级状态(只给判据用)。
|
||||
function Z.resetDiagnostics()
|
||||
profiles = {}
|
||||
nextOrdinal = 1
|
||||
end
|
||||
|
||||
return Z
|
||||
|
||||
]===]
|
||||
|
||||
-- ★ ZCode 3012 判据/文案/诊断的**唯一出处**(插件主体与 channels-zcode 适配器
|
||||
-- 共用同一份源码文本)。
|
||||
--
|
||||
-- 为什么必须共用:3012 不是"重试就能过",而是**有账号冷却惩罚**(30 分钟起步,
|
||||
-- 反复触发升级到 24 小时甚至停用)。"请勿连续重试"是这段文案里最重要的一句,
|
||||
-- 绝不能只在领取路径或只在推理路径上有(参考为此专门把文案抽成单一份)。
|
||||
--
|
||||
-- 本段在插件主体里**本地执行**得到 ZD;适配器里同文本前置进它自己的环境。
|
||||
local ZD = (function()
|
||||
local env = setmetatable({ json = json }, { __index = _G })
|
||||
local f = load(ZCODE_DIAG_SRC, "zcode-diag")
|
||||
if not f then return nil end
|
||||
local ok, mod = pcall(f)
|
||||
if not ok or type(mod) ~= "table" then return nil end
|
||||
return mod
|
||||
end)()
|
||||
|
||||
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;
|
||||
-- ② 日期块(<system-reminder>)**不是**判据 —— 去掉照样 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 适配器
|
||||
-- ============================================================================
|
||||
-- 适配器目录 = 插件目录的**兄弟**目录(<data>/plugins 与 <data>/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():<system-reminder> 日期块(官方逐字形态)。
|
||||
-- ⚠ 照发但**不是准入判据**(见文件头)。形态细节勿"优化":
|
||||
-- 整块是**一个** 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 = "<system-reminder>\n" .. body .. "\n</system-reminder>" }
|
||||
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)
|
||||
|
||||
-- ★ 日期块:只处理**第一条**消息,且它必须是 role == "user"(官方行为)。
|
||||
--
|
||||
-- 两条容易漏掉的规则(照参考 withContextPrefix,「优化」它们会偏离官方):
|
||||
-- ① **幂等**:已以 <system-reminder> 开头则不重复插。不判的话,同一请求
|
||||
-- 在重试/多次 transform 时会把日期块叠成好几层,改变前缀结构;
|
||||
-- ② **只看第一条**:若首条不是 user(例如 assistant 开场),**不动** ——
|
||||
-- 不是"往后找一个 user 消息"。我第一版就是往后找,与官方行为不符。
|
||||
local msgs = req.messages
|
||||
if type(msgs) == "table" then
|
||||
local first = msgs[1]
|
||||
if type(first) == "table" and first.role == "user" then
|
||||
local c = first.content
|
||||
-- 已插过(字符串或块数组两种形态都要判)→ 不重复插。
|
||||
-- ⚠ 长度必须用 #SR 算,不能写死 —— 我第一版写 `sub(1, 16)` 而
|
||||
-- "<system-reminder>" 是 **17** 字符,判断**永远为假** ⇒ 幂等失效、
|
||||
-- 重试时日期块叠加(判据当场抓到)。
|
||||
local SR = "<system-reminder>"
|
||||
local already = false
|
||||
if type(c) == "string" then
|
||||
already = c:sub(1, #SR) == SR
|
||||
elseif type(c) == "table" and type(c[1]) == "table" then
|
||||
local t = c[1].text
|
||||
already = type(t) == "string" and t:sub(1, #SR) == SR
|
||||
end
|
||||
if not already then
|
||||
local blk = contextPrefixBlock()
|
||||
if type(c) == "table" then
|
||||
table.insert(c, 1, blk)
|
||||
elseif type(c) == "string" then
|
||||
-- 纯文本 content 转成块数组(官方就是数组形态)
|
||||
first.content = { blk, { type = "text", text = c } }
|
||||
else
|
||||
first.content = { blk }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- ★ transform_error:把 3012 判成**不可重试**并带上现场诊断。
|
||||
--
|
||||
-- 为什么不能只依赖通用文案:3012 有账号冷却惩罚(30 分钟起步),
|
||||
-- 归成"可重试的服务端错误"会让调用方**白重试**、把冷却越推越长。
|
||||
-- 诊断行(账号序号/成功失败计数/距上条/身份块实测字符数)是排查的全部现场,
|
||||
-- 且**不含任何凭据片段**(只出现进程内自增序号)。
|
||||
function adapter.transform_error(status, body)
|
||||
local text = tostring(body or "")
|
||||
if Z and Z.hasUnusualActivity(text) then
|
||||
return Z.formatUnusualActivityMessage({
|
||||
diagnostic = Z.formatDiagnostic({
|
||||
accountId = nil,
|
||||
sentAt = os.time and os.time() * 1000 or 0,
|
||||
now = os.time and os.time() * 1000 or 0,
|
||||
status = status,
|
||||
}),
|
||||
rawResponse = text:sub(1, 500),
|
||||
})
|
||||
end
|
||||
-- 非风控:交回 anthropic 基座的通用分类
|
||||
if type(BASE) == "table" and type(BASE.transform_error) == "function" then
|
||||
local ok, res = pcall(BASE.transform_error, status, body)
|
||||
if ok and type(res) == "string" then return res end
|
||||
end
|
||||
return text
|
||||
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)
|
||||
-- ============================================================================
|
||||
@ -9029,6 +9823,32 @@ 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
|
||||
-- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。
|
||||
-- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)——
|
||||
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
|
||||
-- ⚠ 不能直接拼接:ZCODE_DIAG_SRC 末尾是 `return Z`,直接拼会让那个 return
|
||||
-- 结束**整个 chunk** ⇒ 后面的适配器代码永不执行("加载成功但适配器不存在",
|
||||
-- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。
|
||||
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
|
||||
"\nend)()\n" .. ZCODE_ADAPTER_SRC
|
||||
local f, lerr = load(full)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-zcode", full)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
|
||||
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
|
||||
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
|
||||
@ -9568,8 +10388,14 @@ function plugin.__set_loopback_probe(args)
|
||||
local file = tostring(args.file or "")
|
||||
if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end
|
||||
if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end
|
||||
plugin.state.loopbacks[sid] = { file = file }
|
||||
return { json = { ok = true, session_id = sid, file = file } }
|
||||
-- ★ 合并而非覆盖:真实会话的条目里带着 pid/port_file,覆盖掉它们
|
||||
-- 会让 reapLoopback 杀不掉那个 python 接收器 —— 探针自己就成了泄漏源
|
||||
-- (实测:覆盖写法在该测试里留下 1 个孤儿进程,而其他测试已归零)。
|
||||
local cur = plugin.state.loopbacks[sid]
|
||||
if type(cur) ~= "table" then cur = {} end
|
||||
cur.file = file
|
||||
plugin.state.loopbacks[sid] = cur
|
||||
return { json = { ok = true, session_id = sid, file = file, kept_pid = cur.pid or "" } }
|
||||
end
|
||||
|
||||
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
|
||||
@ -9915,6 +10741,36 @@ function plugin.claims_capable(args)
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
|
||||
--
|
||||
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在
|
||||
-- channels-zcode 适配器)。判据必须能直接打**那一份**实现,否则会退化成
|
||||
-- “各自测各自的副本”,而共用一旦漂移就再也测不出来。
|
||||
function plugin.__zcode_diag_probe(args)
|
||||
args = args or {}
|
||||
local fn = tostring(args.fn or "")
|
||||
if not ZD then return { json = { error = "诊断模块未装载" } } end
|
||||
local f = ZD[fn]
|
||||
if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end
|
||||
if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end
|
||||
if fn == "formatUnusualActivityMessage" then
|
||||
return { json = { result = f(args.opts or {}) } }
|
||||
end
|
||||
if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end
|
||||
if fn == "describeRequestShape" then
|
||||
return { json = { result = f(args.system, args.messages) } }
|
||||
end
|
||||
if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end
|
||||
if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
|
||||
if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
|
||||
if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end
|
||||
if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity"
|
||||
or fn == "looksLikeUnusualActivity" then
|
||||
return { json = { result = f(args.a) } }
|
||||
end
|
||||
return { json = { error = "探针未暴露该函数: " .. fn } }
|
||||
end
|
||||
|
||||
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
|
||||
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
|
||||
function plugin.__adapter_install_probe()
|
||||
@ -9922,6 +10778,7 @@ function plugin.__adapter_install_probe()
|
||||
codearts = plugin._codearts_adapter,
|
||||
trae = plugin._trae_adapter,
|
||||
gemini = plugin._gemini_adapter,
|
||||
zcode = plugin._zcode_adapter,
|
||||
} }
|
||||
end
|
||||
|
||||
@ -10013,6 +10870,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
|
||||
|
||||
Reference in New Issue
Block a user