mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(zcode): 身份块适配器 + 修复调度器泄漏 + codearts 目录 + 备份裁剪泛化
## 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)。
This commit is contained in:
@ -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()
|
||||
}
|
||||
@ -572,13 +572,24 @@ func (ps *Plugins) Close() {
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
// ★ 顺序至关重要:**先落盘,再给插件收尾**。
|
||||
// ★ 顺序至关重要:**先停调度,再落盘,最后给插件收尾**。
|
||||
//
|
||||
// saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关,
|
||||
// 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的
|
||||
// TestCloseFlushesTail 正是为守住这一点而存在)。
|
||||
//
|
||||
// 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。
|
||||
//
|
||||
// ★ 而调度器必须停在**落盘之前**:它每 tick 可能调 Lua、改状态,
|
||||
// 落在 save.Close 之后就会写进一个已经收尾的 state;停在最前面也顺带
|
||||
// 保证了「收尾期间不会再有新任务被派发」。
|
||||
//
|
||||
// 真实缺陷(与 on_unload 同一形状的第 3 个实例):Runtime.Stop() 此前
|
||||
// **只在单插件 Unload 里调**,Close() 不调 —— 于是每个插件的调度循环
|
||||
// (PluginRuntime.loop)在「进程关闭/测试结束」这条路径上永不停止。
|
||||
// 实测全量回归在 internal/lua 出现 69 个 goroutine 卡在 saver/runtime
|
||||
// 循环里并超时 600s。
|
||||
ps.stopSchedulersAll()
|
||||
// Shard VMs are separate golua states; leaving them open leaks a VM per
|
||||
// shard on every reload.
|
||||
ps.shards.Shutdown()
|
||||
@ -586,6 +597,24 @@ func (ps *Plugins) Close() {
|
||||
ps.callOnUnloadAll()
|
||||
}
|
||||
|
||||
// stopSchedulersAll 停掉每个插件的调度循环(best-effort,幂等)。
|
||||
//
|
||||
// 与 Unload 里的 Runtime.Stop() 是同一契约:Close 是它在进程退出路径上的
|
||||
// 等价点,不能只有 Unload 有 —— 那正是 on_unload 缺陷的形状(“两处只修一处”)。
|
||||
func (ps *Plugins) stopSchedulersAll() {
|
||||
ps.mu.Lock()
|
||||
plugins := make([]*Plugin, len(ps.plugins))
|
||||
copy(plugins, ps.plugins)
|
||||
ps.mu.Unlock()
|
||||
for _, p := range plugins {
|
||||
if p == nil || p.Runtime == nil {
|
||||
continue
|
||||
}
|
||||
p.Runtime.Stop()
|
||||
p.Runtime = nil
|
||||
}
|
||||
}
|
||||
|
||||
// callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。
|
||||
//
|
||||
// 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
|
||||
|
||||
@ -728,7 +728,10 @@ local PLATFORMS = {
|
||||
},
|
||||
hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com",
|
||||
"snap-access.cn-north-4.myhuaweicloud.com",
|
||||
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" },
|
||||
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com",
|
||||
-- benefit 模型目录(照参考 OPENGW_GATEWAY_CONFIG_URL 的 host)。
|
||||
-- 不在白名单会被 HTTP 桥拒,目录只剩第 2 个端点的那一半模型。
|
||||
"opengw.developer.huaweicloud.com" },
|
||||
-- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。
|
||||
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
|
||||
-- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名
|
||||
@ -736,7 +739,31 @@ local PLATFORMS = {
|
||||
-- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名),
|
||||
-- 不是裸 access token。见 syncAccountSource 的注释。
|
||||
credential_shape = "full",
|
||||
models = { "CodeArts-Pangu" },
|
||||
-- ★ 远端模型目录(照参考 fetchCodeArtsRemoteModels 的**两个**端点)。
|
||||
--
|
||||
-- 缺这个曾让 codearts 只剩硬编码的 1 个模型("CodeArts-Pangu"),而
|
||||
-- 10 个账号全在池里可用 —— 目录端点其实一直可达,只是我们没打。
|
||||
-- 参考实测的模型(GLM-5.2 / openpangu 系列 / glm-5.2-sft-harmony 等)
|
||||
-- 全都由这两个端点下发:
|
||||
-- ① opengw gateway/config → result.models(benefit 模型,如 glm-5.3-flash)
|
||||
-- ② snap-access /v1/model/builtin → builtinModels(常规模型)
|
||||
-- 两者都**必须 AK/SK 签名**(ep.sign = "codearts" 走平台签名函数),
|
||||
-- 且 ② 额外要 Agent-Type / X-Language 两个不参与签名的头。
|
||||
models_remote = {
|
||||
endpoints = {
|
||||
{ url = "https://opengw.developer.huaweicloud.com/api/v1/gateway/config",
|
||||
method = "GET", sign = "codearts", parse = "codearts_gateway" },
|
||||
{ url = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
|
||||
method = "GET", sign = "codearts", parse = "codearts_builtin",
|
||||
headers = { ["Content-Type"] = "application/json",
|
||||
["Agent-Type"] = "PromptCenter",
|
||||
["X-Language"] = "zh-cn" } },
|
||||
},
|
||||
auth = "none", -- 签名由 sign 分支负责,不走 Bearer
|
||||
parse = "codearts_builtin",
|
||||
},
|
||||
-- 兜底目录:远端不可达时仍可选中(照参考的已知可用 id)。
|
||||
models = { "CodeArts-Pangu", "deepseek-v4-flash", "GLM-5.3", "GLM-5.2" },
|
||||
checkin = {
|
||||
url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
|
||||
method = "GET",
|
||||
@ -875,7 +902,11 @@ local PLATFORMS = {
|
||||
parse = "zcode_configs",
|
||||
},
|
||||
endpoint = "/api/v1/zcode-plan/anthropic/v1/messages",
|
||||
adapter = "anthropic",
|
||||
-- ★ 不能复用通用 anthropic:zcode 的该通道做**请求体内容检查** —— system 缺
|
||||
-- 官方身份块结构时直接 405 + {"code":3012,...}(实测;与 HTTP 头、版本号、
|
||||
-- 请求频率、tools 声明全无关)。本插件自装的 channels-zcode 复用 anthropic
|
||||
-- 的协议转换,只额外注入身份块(见 ZCODE_ADAPTER_SRC)。
|
||||
adapter = "channels-zcode",
|
||||
-- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL)
|
||||
balance = {
|
||||
url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance",
|
||||
@ -3087,6 +3118,27 @@ local function parseRemoteModels(kind, body, provider)
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "codearts_builtin" then
|
||||
-- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels
|
||||
-- (**对象**,键为序号;用 pairs 兼容对象/数组两形态)。
|
||||
local raw = rec.builtinModels or (rec.data and rec.data.builtinModels)
|
||||
if type(raw) == "table" then
|
||||
for _, m in pairs(raw) do
|
||||
local id = type(m) == "table" and (m.model_id or m.modelId or m.id)
|
||||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "codearts_gateway" then
|
||||
-- 照参考的第 1 个端点:result.models(benefit 模型)。
|
||||
local arr = rec.result and rec.result.models
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
local id = type(m) == "table" and (m.model_id or m.modelId or m.id)
|
||||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "zcode_configs" then
|
||||
-- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象**
|
||||
-- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。
|
||||
@ -3165,9 +3217,36 @@ local function refreshRemoteModels(provider)
|
||||
local extra = plat.headers_fn(tok or "", nil)
|
||||
for k, v in pairs(extra or {}) do headers[k] = v end
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = url, method = ep.method or "GET", headers = headers,
|
||||
})
|
||||
-- ★ 签名端点:codearts(华为 snap-access)的目录**必须 AK/SK 签名**,
|
||||
-- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求
|
||||
-- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头,
|
||||
-- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。
|
||||
local req = { url = url, method = ep.method or "GET", headers = headers }
|
||||
if ep.sign == "codearts" then
|
||||
local cred
|
||||
for _, a in ipairs(poolList(provider)) do
|
||||
if a.enabled ~= false then
|
||||
local raw = __llmsproxy_secrets.get(a.credentialRef)
|
||||
local okj, cj = pcall(json.decode, raw or "")
|
||||
if okj and type(cj) == "table" and (cj.access_key_id or cj.access_key) then
|
||||
cred = cj break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not cred then
|
||||
lastErr = "无可用 AK/SK 凭据"
|
||||
else
|
||||
local sreq, serr = codeartsSignedRequest(req.method, url, cred, nil)
|
||||
if sreq then
|
||||
-- 目录端点额外要求的**非签名**头(照参考 fetchSignedGet 调用点)
|
||||
for k, v in pairs(ep.headers or {}) do sreq.headers[k] = v end
|
||||
req = sreq
|
||||
else
|
||||
lastErr = tostring(serr)
|
||||
end
|
||||
end
|
||||
end
|
||||
local resp, err = __llmsproxy_http(req)
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
|
||||
else
|
||||
@ -7750,7 +7829,22 @@ traeCallback = function(plat, q, s)
|
||||
if account == "" then account = "trae" end
|
||||
|
||||
-- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。
|
||||
local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code")
|
||||
--
|
||||
-- ⚠️ 候选来源必须**逐个取第一个非空**(照参考 authCodeCandidates 的 5 个来源),
|
||||
-- 不能用 `a or b or c` 链:jsonField 取不到时返回空串而非 nil,而 Lua 里
|
||||
-- 空串是**真值**,链会在第一个空串处短路 —— 后续来源永远取不到。
|
||||
-- authCodeInfo 可能是 JSON(含 code/authCode 字段),也可能是**纯 code 字符串**
|
||||
-- (参考注释点名),故把原始值作为最后兜底。
|
||||
local authCode = ""
|
||||
do
|
||||
local info = q.authCodeInfo or ""
|
||||
for _, cand in ipairs({ q.code or "", q.authCode or "",
|
||||
jsonField(info, "code"), jsonField(info, "authCode"),
|
||||
info }) do
|
||||
if type(cand) == "string" and cand ~= "" then authCode = cand; break end
|
||||
end
|
||||
authCode = authCode:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then
|
||||
if authCode and authCode ~= "" then
|
||||
@ -8908,6 +9002,289 @@ end
|
||||
return adapter
|
||||
]==]
|
||||
|
||||
local ZCODE_ADAPTER_SRC = [===[
|
||||
-- ============================================================================
|
||||
-- ZCode 专用适配器(插件自装:channels-zcode)
|
||||
--
|
||||
-- 为什么必须自装而不是复用通用 anthropic:
|
||||
-- zcode 的 /zcode-plan/anthropic 通道对请求体做**内容检查** —— system 缺少
|
||||
-- 官方身份块结构时直接返回
|
||||
-- HTTP 405 + {"code":3012,"msg":"request has been blocked due to unusual activity."}
|
||||
-- 参考实测矩阵(同账号、同验证码来源):
|
||||
-- 无 system → 405 + 3012 ✗
|
||||
-- 仅 cliPrefix(42 字符) → 405 + 3012 ✗
|
||||
-- cliPrefix + stable(约 2894 字符) → 200 ✓
|
||||
-- ⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、请求频率、多轮历史、
|
||||
-- tools 声明、运行时(Electron/curl/Node)**全部无关**(三个账号 17 次实测)。
|
||||
--
|
||||
-- ⚠ 参考 2026-10-03 复测修正了两条旧说法,排查时别按旧的来:
|
||||
-- ① HTTP 状态码是 **405**,不是 403;
|
||||
-- ② 日期块(<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)
|
||||
|
||||
-- 日期块:插到**第一条** user 消息的 content 数组最前(官方形态)。
|
||||
local msgs = req.messages
|
||||
if type(msgs) == "table" then
|
||||
for _, m in ipairs(msgs) do
|
||||
if type(m) == "table" and m.role == "user" then
|
||||
local c = m.content
|
||||
if type(c) == "table" then
|
||||
table.insert(c, 1, contextPrefixBlock())
|
||||
elseif type(c) == "string" then
|
||||
m.content = { contextPrefixBlock(), { type = "text", text = c } }
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- 暴露给判据:身份块长度与装载状态("装了但身份块为空"这类问题没有它只能靠猜)
|
||||
adapter.__identity_probe = function()
|
||||
return {
|
||||
base_loaded = BASE ~= nil,
|
||||
base_err = BASE_ERR,
|
||||
cli_prefix_len = #CLI_PREFIX,
|
||||
stable_len = #STABLE_JOINED,
|
||||
total_len = #CLI_PREFIX + #STABLE_JOINED,
|
||||
expected_min = EXPECTED_MIN_CHARS,
|
||||
}
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
]===]
|
||||
|
||||
-- ============================================================================
|
||||
-- Cline 专用适配器(插件自装:channels-cline)
|
||||
-- ============================================================================
|
||||
@ -9066,6 +9443,24 @@ local function installGeminiAdapter()
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- installZcodeAdapter():装/更新本插件自己的 zcode 适配器。
|
||||
--
|
||||
-- 为什么必须有:zcode 的 /zcode-plan/anthropic 通道做**请求体内容检查** ——
|
||||
-- system 缺官方身份块结构直接 405 + 3012(实测),与 HTTP 头/版本/频率全无关。
|
||||
-- 通用 anthropic 适配器不注入身份块 ⇒ 即使拿到 zcode 账号也**必然**被拒。
|
||||
-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。
|
||||
local function installZcodeAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
local f, lerr = load(ZCODE_ADAPTER_SRC)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-zcode", ZCODE_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-zcode", ZCODE_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
|
||||
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
|
||||
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
|
||||
@ -9965,6 +10360,7 @@ function plugin.__adapter_install_probe()
|
||||
codearts = plugin._codearts_adapter,
|
||||
trae = plugin._trae_adapter,
|
||||
gemini = plugin._gemini_adapter,
|
||||
zcode = plugin._zcode_adapter,
|
||||
} }
|
||||
end
|
||||
|
||||
@ -10056,6 +10452,9 @@ do
|
||||
plugin._qoder_adapter = { ok = qok, err = qerr }
|
||||
local cok, cerr = installClineAdapter()
|
||||
plugin._cline_adapter = { ok = cok, err = cerr }
|
||||
local zok, zerr = installZcodeAdapter()
|
||||
plugin._zcode_adapter = { ok = zok, err = zerr }
|
||||
if not zok then logEvent("ZCode 适配器装载失败: " .. tostring(zerr)) end
|
||||
if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end
|
||||
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
|
||||
if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end
|
||||
|
||||
Reference in New Issue
Block a user