Files
ModelRouter/internal/core/channels_zcode_identity_test.go
JianFeeeee 9e5a5f550b fix(channels): zcode 适配器真正继承 anthropic 基座(此前 9 个函数只有 2 个)
## 缺陷(潜伏,从未被触发)
`channels-zcode` 适配器的手法是把 `anthropic.lua` 源码读进来执行、只覆盖
`transform_request`(身份块注入),其余协议转换全部复用基座。

但那句读取写在**适配器内部**。实测:
- 适配器运行在**另一个沙箱 VM** —— 那里 `__llmsproxy_plugin_dir` 与
  `LLMSPROXY_PLUGIN_DIR` **都不存在**、**也没有文件系统访问**;
- ⇒ `loadAnthropic()` 必然返回 nil,**且静默降级**:适配器照常装载,
  但只有它自己定义的 **2** 个函数,缺
  `transform_response` / `transform_stream_chunk` / `serialize_*` /
  `deserialize_request` 共 **7** 个。

**后果**:内核 `Transform()` 对缺失函数**响亮报错**(不是静默透传)⇒
非流式在响应阶段 502,流式**逐帧**报错 ⇒ 该渠道**完全不可用**。

**为什么一直没暴露**:zcode 从来没有凭据(0 账号)⇒ 源未注册
⇒ 该路径从未被调用过。拿到账号的那一刻才会炸,且报错指向内核而非配置。

## 修法
基座源码改由**插件主体**读好并**内联**进适配器源码(插件 VM 有 io,
实测可读)。与 v2.0.35 buddy 适配器同一手法。

两处必须注意的坑:
1. **不能拼接后 gsub 替换**:gsub 的替换串里 `%` 有特殊含义(`%1` 是捕获
   引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
   改用 `find(ph, 1, true)` + `sub` 的纯字符串替换。
2. **占位符 token 不能在注释里出现字面量**(第一版就栽在这:gsub 先命中了
   解释它的那行注释,替换掉注释、代码里仍留着未定义全局)。
3. 顺带补上**插件主体缺失的 `plugin.adaptersDir()`** —— 原先这个函数只
   存在于 zcode 适配器源码**内部**,插件主体从来没有过。

## 判据
- `internal/core`(**真实路径**,会咬变异):
  - `TestZcodeAdapterInjectsOfficialIdentityBlocks`
  - `TestZcodeAdapterKeepsAnthropicProtocolConversion`(工具调用 / 图像块存活)
  - `TestZcodeAdapterFailsLoudlyWithoutAnthropicBase` —— 语义已随设计更新:
    基座不可得现在应在**安装期**失败(新增 `adapters_dir` 测试缝隙把
    "基座缺失"从运行期逐帧炸变成安装期一条明确错误)
- `internal/lua/adapter_zcode_base_test.go`:验证源码模板 + 合并逻辑,
  **并如实标注边界** —— 它在测试里自己做替换,所以**不能**证明安装处内联了
  非空基座(实测:把 `asrc` 换成空串它不报红;那份由上面的 core 判据覆盖)。

**变异验证**:把安装处的 `asrc` 换成空基座 ⇒ core 三条判据全部报红;
用重构前的适配器源码 ⇒ `TestZcodeAdapterInheritsAnthropicBase` 报红(缺 7 个)。
2026-10-17 00:01:33 +08:00

412 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,但模型
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
//
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
//
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
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)
}
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 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)
}
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 zc["ok"] == true {
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
}
msg, _ := zc["err"].(string)
if !strings.Contains(msg, "anthropic") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
}
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
}
// ★ 日期块必须**幂等**:已以 <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)
}
})
}