产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容, 已部署的机器不会因为顺序问题立刻失效。 - go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。 - `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、 `/usr/local/bin` 目标全部改为 modelrouter。 - 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与 `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。 - 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN / BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` → `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。 - 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退, 避免升级到一半的 profile 被判成"core binary missing"。 - 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、 旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。 - `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**, 否则会部署出一个"能启动但没有 key、没有源"的空实例。 - 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。 兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留, 它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。 本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、 `adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
31 KiB
channels 插件与参考插件的三线对齐表
参考仓:Ebony-Vinyl/dsh-our-free-model(本地克隆
/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model),关键实现位于
vendor/channel-pack/src/(144 个 TS 文件)。
命名规律(照它逐厂商对齐的三条线):
| 后缀 | 职责 | 我们要对齐的东西 |
|---|---|---|
X-auth.ts / X-oauth.ts / login.ts |
登录 | 登录流程、端点、参数、凭据形状 |
X-product.ts / models.ts |
模型获取 | 远端端点、解析路径、兜底表 |
X-adapter.ts / llm-adapter.ts |
接入模型服务 | chat 端点、载荷转换、鉴权头 |
一、登录(line ①)
| 平台 | 参考流程 | 我们的 flow | 状态 |
|---|---|---|---|
| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) |
| minimax | device-poll | device-poll | ✅ |
| cline | device-poll | device-poll | ✅ |
| buddy | state-poll(无回调) | state-poll | ✅ |
| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) |
| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) |
| opencode | paste | paste | ✅(参考本身即粘贴凭据) |
| codearts | PKCE + DPoP ES256 + STS 换 AK/SK | callback | ✅ 本轮修复(此前误标 paste) |
| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) |
| zcode | init-poll | init-poll | ✅ |
| loomy | 微信扫码 poll | wechat-poll | ✅ |
| raccoon | 二维码 poll | qr-poll | ✅ |
codearts 登录(本轮修复的重点)
此前标成 flow="paste"(手填凭据)是错的。参考 login.ts / oauth.ts
默认走新式 IAM OAuth:
- 起本地回调服务器,端口须 ≥10000(
MIN_CALLBACK_PORT,低于此 portal 拒绝), 路径/oauth/callback - 打开
https://codearts.huaweicloud.com/portal/authorize,参数逐项:theme=2、locale=zh-cn、uri_scheme=codearts-agent、client_id=codearts-agent、code_challenge_method=**SHA-256**(不是 RFC 的 S256 —— 错值会让 portal 回退旧 ticket 流程,看起来"也能用"但不是 OAuth)、ticket_id、plugin-name=snap_AIIDE、plugin-version=5.2.0; 不得含auth_callback_url(会被判异常并回退) - 回调收到
code后,带 DPoP 证明向https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens换credentials{access_key_id, secret_access_key, security_token, expiration}refresh_token
- 凭据持久化须含续期材料:
refresh_token/code_verifier/dpop_private_key_jwk
DPoP 细节:ES256 JWS,header={alg,typ:dpop+jwt,jwk}、
payload={htm,htu,iat,jti};签名是 raw r||s 64 字节,而 openssl 输出 DER
——必须转换格式,否则服务端验签失败而字符串看起来完全正常。
终态判定只认 invalid_grant / ErrorCode 含 ExpiredRefreshToken;
不认 InvalidDPoPHeader(那是一次请求层面的拒绝:时钟偏差、proof 重放、
网关抖动都会触发,当终态会把完好账号一步标死)。
旧 ticket 流程(CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket 轮询)
保留作回退。
二、模型获取(line ②)
远端优先、兜底补位的机制已具备(models_remote + effectiveModels)。
本轮把兜底表逐平台补成参考全量:
| 平台 | 参考来源 | 兜底数 | 状态 |
|---|---|---|---|
| trae | TRAE_FALLBACK_MODELS |
28 可见 + 4 hidden | ✅(hidden 单列 models_hidden) |
| minimax | GET /mavis/api/v1/models?region=cn&buildEnv=prod |
4 | ✅ |
| cline | GET /api/v1/models + recommended-models |
3 | ✅ |
| buddy | (无独立表) | 7 | ✅ |
| gemini | — | 2 | ✅ |
| lobsterai | GET /api/models/available(须带 model_flag) |
19 | ✅ |
| opencode | GET /v1/models |
14 | ✅(含 models_free) |
| codearts | opengw gateway/config + /v1/model/builtin |
3 | ⚠️ 见下 |
| qoder | /api/v2/model/list(需 WASM 签名) |
19 | ✅(含 CN 独有 q37fmodel/gm51model) |
| zcode | GET /api/v1/client/configs |
2 | ✅ |
| loomy | GET /api/v1/models(取 type==='chat') |
8 | ✅ |
| raccoon | 远端 + 兜底 | 6 | ✅ |
trae 的 isHidden 语义:browser_use_subagent / explore_sub_agent_v13 /
explore_sub_agent_v2 / summary 是内部子 agent,不进用户可选列表。
model_list 已按此过滤(models_hidden)。
opencode 的 isFree:参考明确标注 7 个模型匿名可用、其余需付费 key。
此前 model_list 把 isFree 硬编码为 false,等于把官方免费模型标成付费。
codearts 模型获取(未闭环)
参考 models.ts 从两个端点拉:
OPENGW_GATEWAY_CONFIG_URL(opengw.developer.huaweicloud.com/api/v1/gateway/config) →result.models,这是 benefit(免费额度)模型权威来源SNAP_MODEL_BUILTIN_URL(/v1/model/builtin)→builtinModels,常规模型
两者都要 AK/SK 的 SDK-HMAC-SHA256 签名。另有 normalizeModelId
(去尾部 -NNNN 日期版本)与 isCodeArtsBenefitModel(决定 chat 是否带
maas_type: benefit,不带则 404 model is not registered)。
这部分尚未实现——我们的 codearts 目前只有 3 个硬编码模型名。
三、接入模型服务(line ③)
本轮发现的最严重问题:源注册时 base_url + adapter.endpoint 拼出的
chat URL 全部缺路径前缀,11 个平台里 11 个都不对。已逐个修正:
| 平台 | 正确 chat 端点 | 修正前 |
|---|---|---|
| trae | …/api/agent/v3/llm_utils_chat |
…/chat/completions |
| cline | api.cline.bot/api/v1/chat/completions |
api.cline.bot/chat/completions |
| lobsterai | …/api/proxy/v1/chat/completions |
…/chat/completions |
| qoder | api2-v2.qoder.sh/model/v1/chat/completions |
…/chat/completions |
| buddy | copilot.tencent.com/v2/chat/completions |
…/chat/completions |
| codearts | …/api/v2/chat/completions |
…/chat/completions |
| opencode | opencode.ai/zen/v1/chat/completions |
opencode.ai/zen/chat/completions |
| raccoon | …/api/web/llm/v2/chat/completions |
…/chat/completions |
| loomy | loomy.xunfei.cn/chat/completions |
loomy.xunfei.cn |
| zcode | zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages |
zcode.z.ai/v1/messages |
| gemini | /v1internal:generateContent(私有动词) |
/v1beta/models/{model}:generateContent(公开,凭据不匹配) |
✅ 已闭环:三个私有协议平台改用插件自装适配器
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
LlmAdapter 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。
内核其实早已实现 __llmsproxy_adapters.install(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 0。现已第一次真正使用:
| 平台 | 自装适配器 | 解决的协议差异 |
|---|---|---|
| codearts | codearts |
华为云 SDK-HMAC-SHA256 AK/SK 签名(canonical/string-to-sign 逐字节) |
| trae | channels-trae |
SOLO 载荷:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 |
| gemini | channels-gemini |
私有 /v1internal: 信封 + 身份五头 + 字母序序列化 + 响应剥一层 |
命名说明:trae 用 channels-trae 而非 trae,因为内置 trae.lua 服务的是
运维自建的 trae-local-api 反代(127.0.0.1:19900,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)——
判据里专门断言内置 trae 未被顶掉。
新增能力:credential_shape。签名平台需要完整凭据 JSON(适配器自己
解 AK/SK),其余平台交裸 access token。此前 syncAccountSource 一律用
credentialOf 取 access_token,于是 codearts 凭据(只有
access_key_id/secret_access_key/security_token)取不到 token →
静默 return、不出源。现已按 plat.credential_shape 区分。
另一处静默失败已修:syncAccountSource 拿到内核 add_source 的失败
(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。
现补失败日志(区分内核 err 与 json.ok==false)。
仍未闭环:gemini 的 sigstore(明确不做)
参考 gemini-sigstore.ts(268 行)持久化 tool-call 签名,供跨请求回填。
我们推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,
适配器如实透传错误。理由与结论已记在此处,避免反复讨论。
四、明确不做的(附理由)
| 参考模块 | 行数 | 为什么不做 |
|---|---|---|
gemini-sigstore.ts |
268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ |
qoder-wasm.ts |
1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 |
zcode-captcha.ts |
2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 |
captcha-*.ts |
1223 | 同上 |
五、本机实测踩到的坑(都已修)
xxd不存在 ——command -v xxd为空,用它取十六进制会静默得到空串。 改用od -An -tx1。openssl ec -text会截断 32 字节 priv(实测只给 30 字节)→d必须用openssl asn1parse的[HEX DUMP];tail -c 32拿到的是 SEC1 结构里 公钥的 y,不是d。- Lua local 前向引用:
local function只从定义处向后可见。hexToBin/b64url/b64urlRaw被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块 之前(本文件累计踩过 6 次)。 min_port参数读到argv[2]与out_path冲突 —— 回调脚本的第 5 个 参数才是 minPort。- Lua 双引号串里的
\1是转义坑(sed 's/…/…/\1/'会报 invalid escape sequence)→ 改用awk -F': ' '{print $NF}'。
为什么参考插件是"正常"的:架构差异(2026-10-13 查证)
用户问「为什么他们就正常」。查完参考仓的架构后,答案是三层结构差异, 不是某一个端点的笔误。
参考的架构:一个平台 = 一个适配器类
参考依赖宿主 SDK 的 seam:import { LlmAdapter } from '@deepseek-ai/dsh-llm'。
每个平台有一个继承基类的真类,同时拥有登录、模型目录、推理三件事:
| 文件 | 类 | 干什么 |
|---|---|---|
trae-adapter.ts |
TraeAdapter |
SOLO 协议转换(config_name/function,非 OpenAI 兼容) |
llm-adapter.ts |
CodeArtsAdapter |
华为云 AK/SK 签名 + maas_type: benefit 判定 |
zcode-adapter.ts |
ZcodeAdapter |
Anthropic Messages 协议(非 OpenAI) |
qoder-adapter.ts |
QoderAdapter |
WASM 加密端点 + 信封剥离 |
gemini-adapter.ts |
GeminiAdapter |
/v1internal: 私有动词 + 字母序信封 |
注册方式是 registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)
(llm-adapter.ts:2080)。
但它不重复:共享层是 src/openai-compat.ts(1196 行)——
serializeMessages(消息序列化)、consumeOpenAiSse(SSE 消费)、
httpErrorCode / isTransportError(错误归类)、userContentParts(多模态)。
12 个 adapter 里有 8 个 import from './openai-compat'。
即:公共协议逻辑一份,平台差异各自一个类。
我们的架构:内核是 proxy,插件只注册源
- 适配器是 Lua 文件(12 个),通过 4 个钩子参与:
transform_request/transform_response/transform_stream_chunk/transform_error,外加build_headers(动态签名,见下)。 - 声明面:
endpoint/endpoint_stream/ingress/headers。 - 插件通过
__llmsproxy_kernel.call("add_source", {source={...}})把平台 注册为上游源,source.adapter引用适配器名。
关键发现:机制早就有,但 channels 插件从没用过
内核完整实现了插件自装适配器:
internal/lua/plugin_adapter_bridge.go
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
- 有归属校验(
adapterOwnership:只能改自己装的) - 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
- 有测试证明可用:
TestPluginInstallsAdapterFromLua(PASS)
但 grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua = 0。
channels 插件一个适配器都没装过 —— 它只会注册 adapter = "openai" 的源。
这就是"为什么我们不正常"的直接原因:平台协议差异被当成适配器名差异。
我们还缺的一层:共享协议模块
参考有 openai-compat.ts;我们没有对应物。openai.lua 自己写明
「Lua adapters have no shared prelude」,safe_tool_id 在 openai.lua 与
anthropic.lua 里各复制了一份(各 6 处引用)。
所以正确做法是两层:
- 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类), 或内核在加载时注入 —— 消掉复制粘贴
- channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
codearts AK/SK 签名)装自己的适配器,而不是硬塞给
openai
所需的适配器能力(crypto + 出站 + 流式)
已核实内核提供:
sha256_hex/hmac_sha256_hex(vm.gosetupGlobals)build_headers(meta)收{url, method, body, api_key, timestamp, client_session, source{name,meta}}—— CodeArts 的 AK/SK 签名用它 (与参考signRequestHuawei同构,但 AK/SK 要从api_key里解出)transform_stream_chunk逐 chunk 调用(provider.go:1295)- 插件有完整 OS 能力(
io.popen/os.execute)→ 可用 openssl 做 EC 签名
未验证的风险
- 适配器 VM 能否访问
__llmsproxy_secrets(AK/SK 要解出 SK 才能签名) - 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)
这两条是下一步落地前必须先探的。
插件自装适配器:机制与判据(2026-10-13)
机制(内核早已具备,本轮才开始使用)
internal/lua/plugin_adapter_bridge.go
InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove
注册点在 bootPluginState,先于 DoString(code) —— 所以插件在脚本执行期
就能装适配器。适配器装在适配器 VM(与插件 VM 分离,各自独立)。
三条能力(实测,不是推断)
internal/lua/plugin_adapter_capability_test.go 锁死:
| 能力 | 结论 | 为什么关键 |
|---|---|---|
| 拿凭据 | 适配器能经 build_headers 的 meta.api_key 拿到(url/method/body 也在) |
AK/SK 签名的前提;同时验不到 __llmsproxy_secrets(只注入插件 VM,安全边界完好) |
| 流式状态 | transform_stream_chunk 跨 chunk 保持同一 Lua 状态(表地址恒定) |
SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 |
| 加密原语 | 适配器可用内核 sha256_hex / hmac_sha256_hex(用标准向量校验) |
签名不需要外部进程 |
踩过的坑(都是"静默失败"类,值得记)
function是 Lua 关键字 —— 写body.function = x是语法错误 ('<name>' expected near 'function'),必须body["function"]。 插件脚本本身能编译通过,只在适配器load()时才暴露。- Lua 长字符串里不能有字面 NUL 字节 —— 报
unfinished long string near '<eof>',且报在文件末尾,完全指不到现场。 - 本 Lua 构建不支持按位异或
~(unexpected symbol near '~')。 - 装载失败只回"源码无法编译"、不给行号 —— 三个自装适配器现统一加
本地预编译护栏(先
load一次拿行号)。上面三个坑都是靠它定位的。 - 抽公共代码要抽最全的实现。前导重构时我先抽了
transform_error的 最小版(只认{error:{message}}),打挂 6 个既有测试 —— 最小版丢掉了 HTML 错误页、裸文本、扁平{code,message}、FastAPI{detail}。 抽最小实现是静默降级:不报错,只是少认几种错误体。 - 判据必须能看见真实函数的返回值。我一度用
kernelTestCore裸加载插件 (没授add_source权限)测源注册,源一直不出;直到把内核原始返回 (kernel action add_source is not granted to plugin channels)带出来才 定位。黑盒断言只在失败时告诉你"不对",不告诉你"为什么"。
判据清单
| 文件 | 锁死什么 |
|---|---|
internal/lua/plugin_adapter_capability_test.go |
三条能力(凭据/流式状态/加密原语) |
internal/lua/adapter_prelude_test.go |
前导注入 + 语义值正确 + 旧适配器行为不变 |
internal/lua/adapter_prelude_equivalence_test.go |
重构前后逐字节等价(含反向断言) |
internal/lua/channels_trae_solo_test.go |
SOLO 7 条规则 + 三处反向断言 |
internal/lua/channels_gemini_adapter_test.go |
身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 |
internal/core/channels_codearts_adapter_test.go |
装机/归属 + Go stdlib 独立复算 HMAC 验签 |
internal/core/channels_private_adapter_wiring_test.go |
三平台的源真的引用了自装适配器 |
生产实测:不可用的真相(2026-10-14)
症状与根因
用户报"完全不可用"。实测(/api/plugins/http/channels/account/list 逐平台):
| 平台 | 账号数 | 有凭据的 |
|---|---|---|
| buddy | 2 | 1 |
| trae | 3 | 0 |
| minimax | 1 | 0 |
| lobsterai | 1 | 0 |
| codearts | 5 | 0 |
| 其余 7 平台 | 0 | 0 |
13 个账号里只有 1 个真有凭据,其余全是占位壳子(has_credential: false)。
占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的
行。此前的判断只看「12 平台 0 个 manual」,那是错的判据 —— manual 是
flow 类型,不是"登录成功"。
排除项(都实测过,不是猜)
| 检查 | 结果 |
|---|---|
| 服务状态 | active,NRestarts=0 |
| 部署一致性 | 4 个插件 sha256 与仓库全部一致;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 |
| 推理链路 | POST /v1/chat/completions 实测返回 "OK"(走 buddy 源、deepseek-v4.1-flash)—— 网关本身完全可用 |
| 登录机制 | minimax 真实返回 user_code=T49S-RC7G + 验证地址;trae 起了服务端浏览器 + 回环回调 |
| 设备码轮询 | 5 次全部 pending(未授权时不误判为失败或成功 —— 这是正确行为) |
结论:不是代码坏了,是授权没完成
剩下的 12 个平台都需要人工动作才能走完登录:
- device-poll(minimax / cline / qoder):把
user_code拿到account.minimax.cn/oauth-authorize之类的官方页面输入 - callback(trae / gemini / lobsterai):在 VNC 窗口里点授权 (回环回调只能由本机解析,远程浏览器永远接不住)
- qr-poll / wechat-poll(raccoon / loomy):扫码
- init-poll(zcode):授权页 + 可能的验证码
这些按设计就必须人工,assistant 无法独立端到端完成。
B 类:实测结论
| 项 | 行数 | 结论 |
|---|---|---|
| qoder-wasm + envelope | 706 + 226 | 二进制不在仓库(find -name '*.wasm' 为空,属构建产物)。没有 qoder-auth-wasm.wasm 无法实现;拿到才有意义 |
| zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且登录本身不需要它 —— captcha 只用于签到/领取环节(zcode-auth.ts:22-23:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 |
| captcha-pool | 280 | 同上,服务于领取环节的验证码供给 |
修正判断:B 类不是"登录闭环的拦路虎"。zcode 的登录(/api/v1/oauth/cli/init
→ poll)不需要验证码;captcha 只在自动领取时出现。所以 B 类的真实价值是
"让领取全自动",而不是"让平台可用"。
真实数据事故:测试覆盖了生产账号池(2026-10-14)
症状
用户报「完全不可用」。逐平台 account/list 后发现:13 个账号里只有 1 个有凭据,
其余是占位壳子;再查发现账号池被清空了(pool 全为空 {}),
而保险库 .secrets/channels.json 里的凭据还在。
症状正是「账号凭空消失」。
根因(实测输出,非推断)
channels 插件用 storeDir() 决定存储位置,它有多级兜底:
env MODELROUTER_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码
测试台没设 MODELROUTER_PLUGIN_DIR,后两级直接命中生产:
测试插件目录 = /tmp/TestXXX/002/plugins
storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出
于是测试加载真 channels.lua 时读的是生产存储、写的也是生产存储。
一次全量 go test ./internal/lua 就把
.channels-store.json 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。
只有 internal/lua 复现,internal/core 不复现 —— 因为 core 的测试台
(kernelTestCore)设了 MODELROUTER_PLUGIN_DIR。这是隔离做得不一致的代价。
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
为什么 DisableStatePersistence 不够
第一版修复给 runtimePlugins 关了持久化,仍然复现:插件声明了 schedule
时内核会自动起调度器(plugins.go: p.Runtime = ps.startRuntime(...)),
真 channels 的 refresh_models 会在 5s 后(first_due_now)跑一轮并
logEvent → markDirty。那是后台 goroutine,比 t.Setenv 的生命周期长 ——
测试结束后它仍可能落盘,此时环境变量已还原。
修法(两层,第二层才是根治)
- 内核把插件的真实目录交给插件(ground truth,不再靠猜):
新增
__llmsproxy_plugin_dir全局(registerPluginDirBridge),storeDir()优先用它。即使没有环境变量也不会落到生产路径 —— 实测:撤掉 env 后storeDir仍落在临时目录。 - 测试台补隔离:
runtimePlugins设 env +DisableStatePersistence()。
判据 + 变异验证
internal/lua/store_dir_isolation_test.go:
storeDir 绝不能是 /etc/llmsproxy 前缀、必须落在临时目录、env 必须真的被设。
变异验证:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了生产目录」;
恢复 → 变绿。
全量回归:9 个包全绿,且 go test ./... 后生产存储未被改动(此前必被改)。
⚠️ 修复过程中我自己踩的坑
恢复账号时我把保险库文件里的密文当成明文,喂给 account/add 的
credential —— 保险库 loadSecretsLocked 会在读取时解密,于是那份凭据被
二次加密(1971 → 2965 字符),该账号的推理开始返回空
(codebuddy_* 源失效,而非池路径 channels:buddy 正常)。
教训:凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API。 要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。
逐模块对照的当前状态(2026-10-14 更新)
穷尽对照参考仓 143 个 .ts 模块 / 77,720 行。约定: ✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做
本轮(第 2 批)新补的
| 模块 | 参考规模 | 补了什么 / 为什么 |
|---|---|---|
dead-model-store |
708 | 上游下架的模型仍显示且标"免费",一点就 404。判据只用阳性 404 证据,绝不拿远端目录反推(buddy 那类平台目录残缺,反推会删可用模型) |
remote-catalog-gate |
114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 |
buddy-balance-rank + loomy-balance-rank + 两个 selector |
~1000 | 余额分档选号:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 |
account-probe |
568 | account_test 此前一个网络请求都不发(恒返回 ok:true/ms:1)—— 假阳性 |
model-gate |
218 | 源改串行 + 按模型节流(并发发起必然浪费一个配额;串行不保证间隔) |
ttl-cache / serial-queue |
291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 |
opencode-capability |
423 | isFree 改由 models.dev 的 cost 权威决定(不再是硬编码快照)。受内核 4 MiB 上限约束不可用,已如实降级为可选增强 |
另修 3 个静默失败:account_test 不发请求、业务级错误(HTTP 200 + body 带
error)被当成功、pcall 只取 ok 不看返回值(把诊断信息扔了)。
仍未实现(按影响面排序)
| 模块 | 行数 | 为什么不做的理由 / 阻塞 |
|---|---|---|
zcode-captcha + 6 个 captcha-* |
~3345 | 阿里云滑块。登录不需要它 —— captcha 只在签到/领取环节(zcode-auth.ts:22-23)。真实价值是"让领取全自动"而非"让平台可用" |
qoder-wasm + qoder-envelope |
932 | wasm 二进制不在仓库(find -name '*.wasm' 为空,属构建产物)。拿不到就无法实现 |
zcode-identity / transport / diagnostics |
1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 |
account-pool / channel-pack-store |
1912 | 我们已自建(形态不同,非缺口) |
opencode-proxy / opencode-rpc |
530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) |
message-shape |
146 | 不适用:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 |
loop-recovery |
346 | 前提不存在:它做的是"循环守卫中止后自动续跑",而我们的内核没有循环守卫 |
reasoning-ladder |
182 | 需要内核支持 efforts 字段(Model 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 |
badge-* / image-budget / ttl-cache(已补) |
~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 |
raccoon-login-page / raccoon-qr |
1124 | 登录页与二维码的完整实现(我们走简化版) |
zcode-carrier-page |
93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) |
cline-* 4 个 / gemini-project / gemini-sigstore / loomy-sign(部分) |
~900 | 平台细节与签名持久化 |
一句话结论
平台登录/凭据/源注册这条主线已打通且逐平台对齐(12 平台零 manual、三平台 自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。 未实现的部分集中在两类:(1) 需要真实浏览器/验证码的领取全自动 (captcha 系 ~3300 行);(2) 二进制缺失(qoder wasm)。 其余是宿主 UI 特性或前提不存在的模块。
第三批(2026-10-14 晚)—— 逐模块对照的新发现
★ 本轮新补的三个真缺陷
① 自动领取每天静默漏领(credits.ts / auto-checkin.ts)
活动刷新时刻各平台不同(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
⇒ 上午看到昨天那条 CLAIMED、报 already-claimed 并记账 ⇒ 当天再不去领。
修法照参考:不配"时刻表"(必然漂移),让产出方声明 covers_today,
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + first_due_now。
② 凭据续期此前是假的(refresh.ts / expiry-sync.ts)
account_refresh 一个换 token 的请求都不发却恒返回 ok:true
⇒ refresh_creds 打「已续期 N 个」而什么都没续 —— 日志在说谎。
这是本轮第三次遇到同形态(前两次:account_test 恒 ok、业务级错误被当成功)。
③ isFree 与声明自相矛盾(cline-models.ts)
cline 的免费模型声明在 models 里 ⇒ 被标成收费。
★ 关键认识:两个参考用的是不同机制 —— cline 用远端 free[],
opencode 用 models.dev 的 cost。混成一条 OR 链会与既有判据正面冲突。
⇒ 加 free_source 由平台声明权威来源。
插件自装适配器(第 3、4 个)
| 适配器 | 解决的问题 |
|---|---|
channels-qoder |
错误信封双层编码;10605(排队) 与 110(额度耗尽) 语义相反,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
channels-cline |
429 无 retry-after 头、等待时长写在英文句子里(Try again in 19h 39m)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ 每 30 秒猛敲上游(比参考的「永远 60 分钟」更糟) |
现共 4 个:channels-trae / channels-gemini / channels-qoder / channels-cline。
对照后判定「不适用」的(附理由,不是"没做")
| 模块 | 为什么不需要 |
|---|---|
account-pool 并发写竞态 |
我们的落盘是同步 tmp+rename(Lua 无异步),每次写序列化当前 state。参考的缺陷是"异步后端 + 共享可变快照"的产物。已加判据钉住(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
gemini-messages SSE 末帧丢失 |
我们的 bufio.Scanner 对无尾换行的最后一行照样产出(已实测自证)。参考那是在 while(!done) 里按行处理所致 |
cline-rate-limit 的 30 分钟策略冷却 |
我们每账号一个源(channels-<plat>-<acct>)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
message-shape |
DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
loop-recovery |
前提不存在(我们的内核没有循环守卫) |
reasoning-ladder |
需要内核支持 efforts 字段(属内核改动,按纪律不做) |
retry-after.ts |
参考那份实现自己就是死代码(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
image-budget |
实际缩放委托给宿主的附件服务,我们无对应物 |
运行环境的一条教训(不是代码缺陷)
全量 go test 曾出现 TestConcurrentPluginAdapterUpdates 挂 59 分钟、
看着像死锁。单独跑 0.058s 通过 ⇒ 真因是 /tmp(8.8G tmpfs)被 Go 构建
缓存占满 100%。教训:构建/测试挂住时先 df -h /tmp(/ 空闲不代表 /tmp 够)。