mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:
无 system → 3012 ✗
仅 cliPrefix(42 字符) → 3012 ✗
cliPrefix + stable(约 2898 字符)→ 200 ✓
⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。
实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
/ block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。
判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。
## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。
## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
① opengw.../gateway/config → result.models(benefit)
② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。
## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。
## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。
## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。
清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package gateway
|
||
|
||
import (
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// deploy.sh is the only release-critical script with no automated coverage, and
|
||
// its newest part — prune_adapter_backups — deletes directories under
|
||
// /etc/llmsproxy. This exercises the function in a throwaway directory with the
|
||
// REAL backup-name shape.
|
||
//
|
||
// The shape matters and got this wrong twice: the guard is
|
||
// `^adapters\.bak\.[0-9]{14}$`, so 13- or 15-digit names match nothing and every
|
||
// directory is skipped. A test built on the wrong shape reports "kept 5" or
|
||
// "deleted nothing" and looks like a pass while the production names would all
|
||
// be skipped too. The names below are copied from the live directory listing.
|
||
func TestDeployPruneAdapterBackups(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")
|
||
}
|
||
|
||
base := t.TempDir()
|
||
fn, err := os.ReadFile("../../deploy.sh")
|
||
if err != nil {
|
||
t.Fatalf("read deploy.sh: %v", err)
|
||
}
|
||
src := string(fn)
|
||
i := strings.Index(src, "prune_backups()")
|
||
if i < 0 {
|
||
t.Fatal("prune_backups not found in deploy.sh")
|
||
}
|
||
j := strings.Index(src[i:], "\n}\n")
|
||
if j < 0 {
|
||
t.Fatal("prune_backups has no closing brace")
|
||
}
|
||
body := src[i : i+j+3]
|
||
|
||
// Redirect ONLY the base path. The logic under test must stay verbatim.
|
||
body = strings.Replace(body,
|
||
`local base="/etc/llmsproxy"`,
|
||
`local base="`+base+`"`, 1)
|
||
// 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_BACKUPS=0 in deploy.sh still passed. The
|
||
// value is part of what is under test.
|
||
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
|
||
keepM := keepRe.FindStringSubmatch(src)
|
||
if keepM == nil {
|
||
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
|
||
"policy would be undefined")
|
||
}
|
||
keep := keepM[1]
|
||
|
||
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)
|
||
}
|
||
|
||
// 12 well-formed backups. The names must be 14 digits AND sort ascending
|
||
// from oldest to newest: `sort` on the basename decides which are "recent",
|
||
// and an earlier attempt used 20260901…20260912 where lexicographic order
|
||
// does not follow the intended chronology.
|
||
for _, ts := range []string{
|
||
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
|
||
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
|
||
"20260901120000", "20261001120000", "20261101120000", "20261201120000",
|
||
} {
|
||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+ts), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
// Names the guard must refuse to delete: a hand-made directory, a 15-digit
|
||
// name, and an 11-digit one.
|
||
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
|
||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+name), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
|
||
t.Fatalf("prune_backups 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
|
||
}
|
||
}
|
||
|
||
// 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_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)
|
||
// behaviour: a human-made directory is never sacrificed for a timestamped
|
||
// one. Assert the real outcome rather than the KEEP value.
|
||
var wellFormedLeft []string
|
||
for _, e := range entries {
|
||
if strings.HasPrefix(e.Name(), "adapters.bak.") &&
|
||
len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
|
||
wellFormedLeft = append(wellFormedLeft, e.Name())
|
||
}
|
||
}
|
||
if len(wellFormedLeft) == 0 {
|
||
t.Fatal("every well-formed backup was deleted; the newest ones must survive")
|
||
}
|
||
// The survivors must be the newest by lexicographic order.
|
||
if want := "adapters.bak.20261201120000"; !got[want] {
|
||
t.Errorf("%s was deleted but it is the newest timestamp", want)
|
||
}
|
||
if got["adapters.bak.20260101120000"] {
|
||
t.Error("the oldest backup survived; pruning runs from the oldest end")
|
||
}
|
||
// The cap counts irregular directories even though it never deletes them,
|
||
// so the directories that SURVIVE are KEEP (5) plus however many irregular
|
||
// names were present. Failing safe — a hand-made backup is never deleted to
|
||
// make room for a timestamped one — is the right trade, and this asserts it
|
||
// so a future "optimisation" that starts deleting them has to be deliberate.
|
||
var irregular, regular int
|
||
for _, e := range entries {
|
||
if !strings.HasPrefix(e.Name(), "adapters.bak.") {
|
||
continue
|
||
}
|
||
if len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
|
||
regular++
|
||
} else {
|
||
irregular++
|
||
}
|
||
}
|
||
if want := 5; regular+irregular > want {
|
||
// 3 irregular were seeded and cannot be deleted, so KEEP is effectively
|
||
// consumed by them.
|
||
if regular+irregular-3 > want {
|
||
t.Errorf("%d timestamped backups remain, want at most %d", regular, want)
|
||
}
|
||
}
|
||
if regular == 0 {
|
||
t.Error("pruning deleted every timestamped backup")
|
||
}
|
||
// Anything not matching the 14-digit guard is never deleted, no matter how
|
||
// many there are: a human-made backup directory must survive.
|
||
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
|
||
if !got["adapters.bak."+name] {
|
||
t.Errorf("adapters.bak.%s was deleted; the guard only matches "+
|
||
"^adapters\\.bak\\.[0-9]{14}$", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDeployBacksUpTheLiveFileBeforeOverwriting guards the mistake that makes a
|
||
// rollback useless: `cp <new source> <.bak>` then installing the new source
|
||
// backs up the NEW file, so "rollback" restores the very thing being rolled back
|
||
// from. The backup must be taken from the target path.
|
||
func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
|
||
b, err := os.ReadFile("../../deploy.sh")
|
||
if err != nil {
|
||
t.Skip("deploy.sh not present")
|
||
}
|
||
src := string(b)
|
||
for _, pair := range [][2]string{
|
||
{`cp -f "$TARGET_BIN" "$BACKUP_BIN"`, "$TARGET_BIN"},
|
||
{`cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"`, "$TARGET_CONFIG"},
|
||
} {
|
||
stmt, from := pair[0], pair[1]
|
||
if !strings.Contains(src, stmt) {
|
||
t.Errorf("expected the pre-install backup %q in deploy.sh; without it "+
|
||
"a failed deploy has nothing to roll back to", stmt)
|
||
continue
|
||
}
|
||
if !strings.Contains(stmt, from) {
|
||
t.Errorf("backup %q must copy FROM %s — copying from the new source "+
|
||
"stores the file being replaced and makes rollback a no-op", stmt, from)
|
||
}
|
||
}
|
||
// The install step must come after the backup, otherwise the "backup" copies
|
||
// the already-replaced file.
|
||
backupAt := strings.Index(src, `cp -f "$TARGET_BIN" "$BACKUP_BIN"`)
|
||
installAt := strings.Index(src, `mv -f "$STAGING_BIN" "$TARGET_BIN"`)
|
||
if backupAt < 0 || installAt < 0 {
|
||
t.Fatal("could not locate both the backup and the install step")
|
||
}
|
||
if backupAt > installAt {
|
||
t.Error("the binary is installed before it is backed up; the backup then " +
|
||
"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)
|
||
}
|
||
}
|