diff --git a/internal/core/channels_zcode_identity_test.go b/internal/core/channels_zcode_identity_test.go index f62d474..a0aad61 100644 --- a/internal/core/channels_zcode_identity_test.go +++ b/internal/core/channels_zcode_identity_test.go @@ -252,14 +252,16 @@ func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) { }) } -// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。 +// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。 // // 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块 // 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型 // 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。 // -// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得), -// transform_request 必须返回错误而不是原样透传。 +// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有 +// +// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得 +// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。 func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) { // 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到 dir := t.TempDir() @@ -278,21 +280,26 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) { if err := ps.LoadSource("channels", src); err != nil { t.Fatal(err) } - vm := c.VM() - if vm == nil { - t.Fatal("VM 不可用") + // 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic + res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{ + "adapters_dir": filepath.Join(dir, "adapters-no-base"), + }) + if err != nil { + t.Fatal(err) } - // 插件自装时若基座读不到,适配器仍应装载(身份块是静态的), - // 但调用必须报错 - out, err := vm.Transform("channels-zcode", "transform_request", - `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) - if err == nil { - t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out) + j := res.(map[string]interface{})["json"].(map[string]interface{}) + zc, ok := j["zcode_reinstall"].(map[string]interface{}) + if !ok { + t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j) } - if !strings.Contains(err.Error(), "基座") { - t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err) + if zc["ok"] == true { + t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc) } - t.Logf("✓ 基座缺失时响亮失败: %v", err) + msg, _ := zc["err"].(string) + if !strings.Contains(msg, "anthropic") { + t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg) + } + t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg) } // ★ 日期块必须**幂等**:已以 开头则不重复插。 diff --git a/internal/lua/adapter_zcode_base_test.go b/internal/lua/adapter_zcode_base_test.go new file mode 100644 index 0000000..d6d1c01 --- /dev/null +++ b/internal/lua/adapter_zcode_base_test.go @@ -0,0 +1,97 @@ +package lua + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。 +// +// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、 +// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个 +// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。 +// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。 +// +// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换, +// +// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」, +// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」—— +// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。 +// 安装接线由 internal/core 的端到端判据覆盖: +// TestZcodeAdapterInjectsOfficialIdentityBlocks +// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活) +// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错) +// 这三条**会**咬住"内联空基座"这个变异(已实测)。 +// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。 +func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) { + psrc, _ := os.ReadFile("plugins/channels.lua") + s := string(psrc) + diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1] + ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1] + asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua") + if err != nil { + t.Fatal(err) + } + + full := "local Z = (function()\n" + diag + "\nend)()\n" + ad + // ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。 + // 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。 + if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") { + full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc))) + } else { + t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" + + "适配器沙箱里读不到文件)") + } + + vm := NewVM(t.TempDir()) + defer vm.Stop() + if err := vm.LoadAdapterSource("channels-zcode", full); err != nil { + t.Fatalf("装载失败: %v", err) + } + want := []string{"transform_request", "transform_response", "transform_stream_chunk", + "transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble", + "serialize_response", "deserialize_request"} + var lack []string + for _, f := range want { + if !vm.HasFn("channels-zcode", f) { + lack = append(lack, f) + } + } + if len(lack) > 0 { + t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+ + "每次调用都会报 'missing ' ⇒ 渠道完全不可用", len(lack), lack) + } + // 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应 + out, err := vm.Transform("channels-zcode", "transform_response", + `{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`) + if err != nil { + t.Fatalf("transform_response 调用失败: %v", err) + } + if !strings.Contains(out, "hi") { + t.Errorf("基座 transform_response 未产出内容: %s", out) + } + t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out) +} + +func quoteLua(s string) string { + var b strings.Builder + b.WriteByte('"') + for i := 0; i < len(s); i++ { + switch c := s[i]; c { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + default: + b.WriteByte(c) + } + } + b.WriteByte('"') + return b.String() +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 6b3805f..bcaccbc 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -77,6 +77,28 @@ plugin.K = { ZCODE_ORIGIN = "https://zcode.z.ai", } +-- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录 +-- (/plugins 与 /adapters)。由插件目录推导,不猜绝对路径 +-- (测试与多实例部署都靠它隔离)。 +-- +-- ⚠️ 为什么必须放在**插件主体**:适配器运行在**另一个沙箱 VM**, +-- 那里 `__llmsproxy_plugin_dir` 与 `LLMSPROXY_PLUGIN_DIR` **都不存在**、 +-- **也没有文件系统访问** ⇒ 在适配器里读任何文件都拿不到东西且**静默降级** +-- (实测:zcode 适配器因此只拿到自己定义的 2 个函数,缺 7 个, +-- 每次调用内核报 "missing transform_response" ⇒ 渠道完全不可用)。 +-- 所以凡是要"读兄弟文件"的,一律在插件主体做好再内联进去。 +function plugin.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 + -- 申请的内核权限。设置页会把"申请中但未授权"标红。 -- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。 -- @@ -10201,38 +10223,23 @@ local EXPECTED_MIN_CHARS = 2800 -- ============================================================================ -- 复用 anthropic 适配器 -- ============================================================================ --- 适配器目录 = 插件目录的**兄弟**目录(/plugins 与 /adapters)。 --- 由插件目录推导,不猜绝对路径(测试与多实例部署都靠它隔离)。 -local function adaptersDir() - local pd = __llmsproxy_plugin_dir - if type(pd) ~= "string" or pd == "" then - pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or "" - end - if type(pd) ~= "string" or pd == "" then return nil end - local base = pd:gsub("/+$", "") - local parent = base:match("^(.*)/[^/]+$") - if not parent then return nil end - return parent .. "/adapters" -end - --- loadAnthropic():在本地环境里执行内核的 anthropic 适配器源码,拿回它的表。 -local function loadAnthropic() - local dir = adaptersDir() - if not dir then return nil, "无法推导适配器目录" end - local f = io.open(dir .. "/anthropic.lua", "r") - if not f then return nil, "读不到 anthropic.lua(" .. dir .. ")" end - local src = f:read("*a") - f:close() - local chunk, lerr = load(src, "anthropic.lua") - if not chunk then return nil, "anthropic.lua 无法编译: " .. tostring(lerr) end +-- ★★ 基座由**插件主体内联**进来(见下方 §基座占位符), +-- **不能**在适配器里自己读文件。实测(2026-10-16): +-- 适配器运行在**另一个沙箱 VM**,那里 `__llmsproxy_plugin_dir` 与 +-- `LLMSPROXY_PLUGIN_DIR` **都不存在**,**也没有文件系统访问** +-- ⇒ 原先那段"就地读 anthropic.lua"必然失败且**静默降级**: +-- 适配器照常装载,但只有它自己定义的 2 个函数, +-- 缺 transform_response / transform_stream_chunk 等 **7 个**。 +-- ⇒ 后果:每次调用内核对缺失函数**响亮报错**(不是静默透传) +-- ⇒ 流式请求逐帧报错 ⇒ 该渠道**完全不可用**。 +-- 该缺陷一直潜伏:zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。 +local BASE = (function() + local chunk, lerr = load(__INLINE_ANTHROPIC_SRC__, "anthropic-base.lua") + if not chunk then return nil, 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 not ok or type(base) ~= "table" then return nil, tostring(base) end + return base +end)() if BASE then for k, v in pairs(BASE) do if adapter[k] == nil then adapter[k] = v end @@ -10617,8 +10624,18 @@ end -- system 缺官方身份块结构直接 405 + 3012(实测),与 HTTP 头/版本/频率全无关。 -- 通用 anthropic 适配器不注入身份块 ⇒ 即使拿到 zcode 账号也**必然**被拒。 -- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。 -local function installZcodeAdapter() +-- dirOverride 仅用于测试/诊断:强制指定适配器目录,让"基座不可得" +-- 成为**安装期可观测**的失败(否则它只会在运行期逐帧炸,且原因指向内核)。 +local function installZcodeAdapter(dirOverride) if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + -- ★ 读到 anthropic 基座源码并在**插件主体**内联进适配器源码。 + -- ⚠ 不能在适配器里自己读文件:适配器沙箱没有 fs、也没有 plugin_dir, + -- 那样会**静默降级**成"只有自己定义的 2 个函数"(见适配器顶部注释)。 + local adir = dirOverride or plugin.adaptersDir() + if not adir then return false, "无法推导适配器目录" end + local af = io.open(adir .. "/anthropic.lua", "r") + if not af then return false, "读不到 anthropic.lua(" .. adir .. ")" end + local asrc = af:read("*a"); af:close() -- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。 -- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)—— -- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。 @@ -10627,6 +10644,13 @@ local function installZcodeAdapter() -- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。 local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC .. "\nend)()\n" .. ZCODE_ADAPTER_SRC + -- ⚠ 用**纯字符串替换**而不是 gsub:gsub 的替换串里 `%` 有特殊含义 + -- (`%1` 是捕获引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。 + -- ⚠ 占位符 token 刻意在注释里**不写字面量**,否则 find 会先命中注释。 + local ph = "__INLINE_ANTHROPIC_SRC__" + local qi = full:find(ph, 1, true) + if not qi then return false, "适配器源码缺少基座占位符" end + full = full:sub(1, qi - 1) .. string.format("%q", asrc) .. full:sub(qi + #ph) local f, lerr = load(full) if not f then return false, "源码语法错误: " .. tostring(lerr) end local _, uerr = __llmsproxy_adapters.update("channels-zcode", full) @@ -11744,13 +11768,21 @@ end -- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的 -- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。 -function plugin.__adapter_install_probe() +function plugin.__adapter_install_probe(args) + args = args or {} + -- 带 adapters_dir 时**重新安装** zcode:用来验证「基座不可得 ⇒ 安装期报错」。 + -- 之所以要这个缝隙:基座现在由**插件主体内联**(适配器沙箱无 fs), + -- 安装失败若不在此暴露,就只会在运行期逐帧炸,且错误指向内核而非配置。 + if args.adapters_dir then + local ok, err = installZcodeAdapter(args.adapters_dir) + return { json = { zcode_reinstall = { ok = ok and true or false, err = err } } } + end return { json = { - codearts = plugin._codearts_adapter, - trae = plugin._trae_adapter, - gemini = plugin._gemini_adapter, - zcode = plugin._zcode_adapter, - } } + codearts = plugin._codearts_adapter, + trae = plugin._trae_adapter, + gemini = plugin._gemini_adapter, + zcode = plugin._zcode_adapter, + } } end -- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并 @@ -11850,4 +11882,6 @@ do if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end end + + return plugin