| Files | Last commit | Last update |
|---|---|---|
fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器 生产实测逐渠道排查(2026-10-19): * qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串; qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test 走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包 {access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。 * trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧), transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报 invalid character 'e'。补 aggregate_solo_sse 聚合。 * buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制 stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。 * provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为 tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。 * opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML); 兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。 opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹 分桶),保留免费层门禁四要素。 * 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的 隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。 * channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号 闪现在 opencode 卡片下的渲染竞态。 * channels request_end hook:挂接丢失的 isModelGone/recordDeadModel —— 真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进 deadModels,不再每轮 404 仍可选中。 (cherry picked from commit 2113bfc125e9cab60595d18af65ab29675e7caef) | 19 days ago | |
fix(codearts): 上游限流改为可声明重试(内核通用能力)+ 会话/连接两处真因 用户连续报「codeart 还是不可用 / empty stream」。把上游原始响应打出来逐条对齐, 三个症状同源,且都不是"模型坏了": | 场景 | 上游实际返回 | |---|---| | 流式、短时间连续请求 | HTTP **200** + 只带 error 的帧 ⇒ 内核判 empty stream | | 非流式、同一条件 | HTTP 400 TM.00001041: 并发会话数已达上限(3个) | | 突发请求 | HTTP 429 TPM limit … 500000 tokens per minute | 实测:最后一个请求结束后约 **44~60 秒**释放;同一账号连续请求 3~4 次即占满。 参考实现(示例插件 llm-adapter.ts)的做法是**每 10s 轮询后重试 chat**(最多 30 分钟),所以用户那边"看起来没事"。 ## 我们的改法(内核只做通用机制,厂商知识留在插件) 1. **内核新增声明式重试策略** source.retry(纯数据:状态码列表 + 响应体子串 + 间隔 + 次数)。内核不认识任何厂商;命中即在同一网关请求内每 10s 重试一次。 流式只在**握手阶段**重试(首帧尚未下发,不会重复输出),非流式整体重试。 2. **插件为 codearts 声明**:400/429 + 命中 TM.00001041 / TPM limit / 并发会话数已达上限 → 10s × 最多 18 次(≈3 分钟)。 3. **适配器补两个会话细节**:Connection: close(keep-alive 空闲连接会被上游 计入并发会话)与稳定 Session-Id(同一账号恒定,避免每次请求新建会话)。 4. 错误文案补可行动提示(并发会话上限 / TPM 限流,约 1 分钟释放)。 ## 验证(生产) - 连续 6 次**流式**:6/6 成功(撞上限时自动等 10s 再试,客户端只感觉慢): +0s / +12s / +34s / +86s / +100s / +144s - 连续 5 次**非流式**:5/5 成功(+0 / +11 / +20 / +28 / +38s) - 修复前:第 4 个请求起必然 TM.00001041 / empty stream。 > 注:TM.00001005 无正常可用套餐 属于账号权益问题(不重试、如实报出), > 与限流要分开处理。 (cherry picked from commit 6454b094bc5cfa0ff2a07597785da3ab898aec7f) | 19 days ago | |
fix(buddy): 新增内置 buddy 适配器(glm-5.1 的 reasoning_effort + 流式 Accept) ## 真实缺陷 ①:glm-5.1 恒 400(用户报"这个模型不可用") 上游回 400 {"code":11150,"msg":"the reasoning effort value is not supported by the current model"}。探针逐字对照实验(body 完全由探针给定): glm-5.1 裸(无该字段) → 400 11150 ✗ glm-5.1 + reasoning_effort:medium → 200 ✓ glm-5.1 空串 → 400 11150 ✗ **任何非空档位都被接受**(low/medium/high/xhigh/max/minimal 逐个 200); 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)⇒ 这是 glm-5.1 **独有**要求。 参考 product.ts:237 声明它只有 ['medium'] 一档。 ## 真实缺陷 ②:流式请求缺 Accept: text/event-stream → 空回复 不发该头时上游**接受请求(200)但只回一帧空内容**(completion_tokens=0、 finish_reason 直接 stop),既不报错也不产出正文 —— 表现是"模型不回答", 极易误判成模型坏了或额度不足。同 body 加该头 → 39~43 帧正常 SSE。 参考 buddy-adapter.ts 的 send() 固定发它。 ## 实现:内置 buddy.lua(不是插件自装) **为什么不用插件自装适配器**(channels-zcode/qoder/cline 那条路): channels.lua 的**主 chunk 已 248 个 local**,Lua/LuaJIT 上限是 200 —— 超出的 local 会**静默退化为全局**(luac -p 通过,运行时才 attempt to call global 'x' (a nil value))。实测证据:adaptersDir 是第 208 个 ⇒ 调用点拿到 nil ⇒ loadAnthropic 静默返回 nil ⇒ **zcode 适配器从来没有基座** ("复用 anthropic"的意图从未生效,只是它自写了全部转换所以能用)。 我第一版就是这么写的,结果适配器装不上(adapter channels-buddy not loaded), 且 hy3/glm-5.3/kimi-k3-1 全被打成空回复 —— 只有 glm-5.2 因多源兜底 "看起来正常",症状再次指向错误方向。 ⇒ 改走内置适配器(与 trae/sensenova/opencodezen 同性质,deploy.sh 自动同步), channels.lua 只改**一行** adapter = "buddy",不再触碰那个已贴上限的文件。 ## ★ 排查中发现的沙箱事实(值得记) **适配器运行在另一个沙箱 VM**:那里 __llmsproxy_plugin_dir 与 LLMSPROXY_PLUGIN_DIR **都不存在**(实测均为 nil),**也没有文件系统访问**。 故任何"在适配器里读兄弟适配器源码"的写法都拿不到基座且静默降级。 判据 TestBuddyAdapterDefinesAllTransforms 咬住这条。 ## 顺带修:hy4-preview-x 从目录剔除 实测它对上游**恒回 11102**(model service info not found,加/不加 reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。它被服务端 agents 引用,于是 agentRef 例外把它放回目录 ⇒ 用户选中必 400 而错误指向模型。 参考用**白名单重建**达到同样效果(其兜底表里没有它)。本仓加等价的精确黑名单 (只放实测确认的,不做猜测),并在两处兜底目录里一并剔除。 ## 验证 buddy **15/15 模型全部可用**(含 glm-5.1),逐一实测无失败; hy4-preview-x 已从源目录消失(16 → 15)。 ## 判据(3 条 + 变异 2/2) - TestBuddyAdapterEffortAndHeaders:glm-5.1 补 medium / hy3 不补 / 调用方给的档位被尊重 / Accept 必须是 SSE + 身份头齐备 - TestBuddyAdapterKeepsOpenAIWire:协议转换与 openai 一致(只是补两个字段) - TestBuddyAdapterDefinesAllTransforms:**四个转换函数都存在** —— 咬"静默降级"而非"某模型能回话"(后者会被多源兜底掩盖,本会话已栽两次) - 变异:删 stream_chunk / 不补 effort → 均报红 ## 一起提交的其它独立缺陷(均在本次调查中发现) - buddy 目录过滤(四类判据)与 hy4-preview-x 剔除 | 23 days ago | |
chore(main): 回流 —— opencode Zen 免费层可用 + noVNC path 相对名修复 内容与 release/v2.0.x 完全一致(git checkout release/v2.0.x -- .): - feat(opencode): Zen 免费层门禁四要素 + 两个 id 公式(详见 release 提交) - fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接 | 24 days ago | |
fix(opencodezen): 必须回传 reasoning_content(thinking 模式硬要求) ## 真实缺陷(我自己的会话被它打断过) 生产实测(2026-10-16): provider localzen: api error 400: Upstream request failed: [invalid_request_error] The reasoning_content in the thinking mode must be passed back to the API. 审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。 根因:transform_request 里写着 msg.reasoning_content = nil,注释理由是 "Zen 免费池不接受回传(Go 侧相反)"。**该假设方向是反的** —— 上游要的就是它。 ## ★ 为什么这个缺陷极难归因(值得记) 失败是**交替**出现的:只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启 + 长上下文)时,回放那段历史才会被拒;普通回合照常 200。 于是"同一个模型同一会话,成功与失败交替"——看起来像**上游抖动或限流**, 而客户端看到的 502 与"上一轮想过"这件事**毫无关联**。 我一开始也走了弯路:用简短的 curl 复现不出来(8/8 成功),因为那些请求的 历史里没有"模型自己产出的 reasoning"。**必须让上一轮真的产生思考**才复现。 参考实现的方向一致:openai-compat.ts 的 serializeMessages(opencode 适配器 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段: ...reasoning.length > 0 ? { reasoning_content: reasoning } : {} 即「有就回传」,无额外条件。 ## 修法 保留 reasoning_content(只把**空串**丢掉 —— 多余字段部分上游会挑)。 不改成"仅当 thinking 开启才回传":我们**无法**从请求可靠判定上游是否处于 thinking 模式(真正知道的是上游)。而"有就回传"是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),需要时缺了就是硬 400。 ## ★ 顺带发现的第二处问题 提交 6113924 的说明声称 "Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split" —— **该测试在全仓不存在**(git log -S 全历史搜索为空)。也就是说这条行为 **从未被任何判据固定**,所以它反了也没人知道。本轮补上真正的判据。 ## 判据(2 条 + 变异 1/1) - TestOpenCodeZenKeepsAssistantReasoningContent:assistant 回合带 reasoning 时 出站体必须有它。**咬住"被剥掉"这个具体行为**,而不是"请求成功"—— 后者在非 thinking 回合下永远为真,测不出本缺陷。 - TestOpenCodeZenDropsEmptyReasoningContent:空串必须丢掉(配对判据, 防"改成无条件保留"这类过度修正)。 变异:恢复 msg.reasoning_content = nil → 报红。 ## 说明 opencodezen.lua 在仓库内,而 deploy.sh 的 sync_adapters 只覆盖 **与仓库同名**的适配器文件 ⇒ 本修复会自动生效(生产另有 opencodego/opencode/ agentrouter 三个仅存在于线上的文件,不受影响)。 | 23 days ago | |
chore(main): 回流 —— opencode Zen 免费层可用 + noVNC path 相对名修复 内容与 release/v2.0.x 完全一致(git checkout release/v2.0.x -- .): - feat(opencode): Zen 免费层门禁四要素 + 两个 id 公式(详见 release 提交) - fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接 | 24 days ago | |
fix(channels): 商汤扫码链路重建 + 双区登录 + 凭据过期口径 + 四条老化判据 ① 商汤(Raccoon)扫码「链接无效」——两个真根因 - 登录页 /login/mp 已被站点删除(Next 路由 404,实测只有 404 页); - 旧实现本地随机造 qrcode_code,而上游早已不认自造码:旧端点 /api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending (服务端没有该会话)⇒ 扫码永远完不成,账号一直是空凭据。 修法:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk)—— POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 /login?code=…&appname=…&source=platform(实测 200 + 微信扫码面板) → 轮询 /api/iam/v1/auth/mp/qrcode/login(IAM code:"OK" 与旧 code:0 都认)。 服务端建码失败才退回旧本地码并打 warn。UI 对扫码流程改文案为 「用微信扫码」(原本文案会让人以为点开就自动登录)。 生产实测:链接 HTTP 200、轮询回 pending(会话真实存在)。 ② 双区登录(P7) - 凭据增加 region(intl|cn,缺省=历史行为);start_login 接受 region, 会话按区域选端点,完成登录写进凭据; - CodeBuddy:cn=copilot.tencent.com(platform=ide)/ intl=www.workbuddy.ai(platform=workbuddy-ai,X-Domain、 X-Product-Code、UA 按区域;实测 auth/state 回 200 信封); - Qoder:intl=openapi.qoder.sh+qoder.com(client_id e883ade2-…)/ cn=openapi.qoder.com.cn+qoder.cn(client_id 不同 732aef47-…,照参考 QODER_CN)。CN 无公开 chat 端点 ⇒ CN 只做登录/领取; - UI:buddy/qoder 详情页增加「+国际区」按钮。 ③ 凭据过期时刻的时区口径(P6 真根因) credentialExpiresAtMs 把 ISO 过期时刻按本地时区解析,而 codearts 给的是 UTC ISO(2026-10-19T16:42:43.06Z)⇒ 本机 +08 下过期时刻虚早 8 小时 ⇒ refresh_creds 每 30 分钟都判"已过期"并对华为云真打一次 STS(token 本还有 23 小时寿命)。改为按声明时区解析(Z / ±hh:mm / 无后缀=UTC 三种形态同一 epoch),补 TestCredentialExpiresAtMsParsesISOAsUTC。 ④ 四条老化/假红判据 - TestCreditsPoolReason*:account_add 触发的选号会用构造期默认余额 (0,0) 先查一次并缓存 60s ⇒ 之后改假上游读不到("余额 100 却报 exhausted")。 harness 改余额后清 TTL 缓存(__ttl_reset_probe,与生产同一实现)。 - TestAccountReorderActually... / TestPoolListOrderIsDeterministic:随 「一通道一源」失效(按 channels-buddy-<acct> 过滤恒得空)。改写为—— 重排必须切换服务账号(新增 __chosen_account_probe),顺序确定性改为断言 选号顺序(__rank_accounts_probe)由 id 稳定决定。 - channels.lua 顶层 local 余量:区域表与 5 份适配器源码常量收进 plugin.K (TestChannelsLuaHasLocalHeadroom 需要 ≥10 槽位);ZCode 适配器源码里的 CLI_PREFIX 必须留在源码内(适配器沙箱没有 plugin 全局)。 ⑤ 错误文案 openai_error 增补腾讯 codebuddy 三层嵌套 {error:{data:{code,msg}}}:实测 429 code=14018「额度已用尽」此前落到"不识别",客户端只看到通用文案。 验证:core 434s 全绿、lua 62s 全绿、provider 全绿;生产已部署并实测 raccoon 链接 200 / trae 非流式 200 / zcode 适配器加载。 (cherry picked from commit 662f8f02b6282d71d243ecd01a0bbfbcface88d4) | 19 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
fix(channels): 商汤扫码链路重建 + 双区登录 + 凭据过期口径 + 四条老化判据 ① 商汤(Raccoon)扫码「链接无效」——两个真根因 - 登录页 /login/mp 已被站点删除(Next 路由 404,实测只有 404 页); - 旧实现本地随机造 qrcode_code,而上游早已不认自造码:旧端点 /api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending (服务端没有该会话)⇒ 扫码永远完不成,账号一直是空凭据。 修法:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk)—— POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 /login?code=…&appname=…&source=platform(实测 200 + 微信扫码面板) → 轮询 /api/iam/v1/auth/mp/qrcode/login(IAM code:"OK" 与旧 code:0 都认)。 服务端建码失败才退回旧本地码并打 warn。UI 对扫码流程改文案为 「用微信扫码」(原本文案会让人以为点开就自动登录)。 生产实测:链接 HTTP 200、轮询回 pending(会话真实存在)。 ② 双区登录(P7) - 凭据增加 region(intl|cn,缺省=历史行为);start_login 接受 region, 会话按区域选端点,完成登录写进凭据; - CodeBuddy:cn=copilot.tencent.com(platform=ide)/ intl=www.workbuddy.ai(platform=workbuddy-ai,X-Domain、 X-Product-Code、UA 按区域;实测 auth/state 回 200 信封); - Qoder:intl=openapi.qoder.sh+qoder.com(client_id e883ade2-…)/ cn=openapi.qoder.com.cn+qoder.cn(client_id 不同 732aef47-…,照参考 QODER_CN)。CN 无公开 chat 端点 ⇒ CN 只做登录/领取; - UI:buddy/qoder 详情页增加「+国际区」按钮。 ③ 凭据过期时刻的时区口径(P6 真根因) credentialExpiresAtMs 把 ISO 过期时刻按本地时区解析,而 codearts 给的是 UTC ISO(2026-10-19T16:42:43.06Z)⇒ 本机 +08 下过期时刻虚早 8 小时 ⇒ refresh_creds 每 30 分钟都判"已过期"并对华为云真打一次 STS(token 本还有 23 小时寿命)。改为按声明时区解析(Z / ±hh:mm / 无后缀=UTC 三种形态同一 epoch),补 TestCredentialExpiresAtMsParsesISOAsUTC。 ④ 四条老化/假红判据 - TestCreditsPoolReason*:account_add 触发的选号会用构造期默认余额 (0,0) 先查一次并缓存 60s ⇒ 之后改假上游读不到("余额 100 却报 exhausted")。 harness 改余额后清 TTL 缓存(__ttl_reset_probe,与生产同一实现)。 - TestAccountReorderActually... / TestPoolListOrderIsDeterministic:随 「一通道一源」失效(按 channels-buddy-<acct> 过滤恒得空)。改写为—— 重排必须切换服务账号(新增 __chosen_account_probe),顺序确定性改为断言 选号顺序(__rank_accounts_probe)由 id 稳定决定。 - channels.lua 顶层 local 余量:区域表与 5 份适配器源码常量收进 plugin.K (TestChannelsLuaHasLocalHeadroom 需要 ≥10 槽位);ZCode 适配器源码里的 CLI_PREFIX 必须留在源码内(适配器沙箱没有 plugin 全局)。 ⑤ 错误文案 openai_error 增补腾讯 codebuddy 三层嵌套 {error:{data:{code,msg}}}:实测 429 code=14018「额度已用尽」此前落到"不识别",客户端只看到通用文案。 验证:core 434s 全绿、lua 62s 全绿、provider 全绿;生产已部署并实测 raccoon 链接 200 / trae 非流式 200 / zcode 适配器加载。 (cherry picked from commit 662f8f02b6282d71d243ecd01a0bbfbcface88d4) | 19 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
feat(adapters): qwen38 —— tokenplan/discovery-api 适配器 接 https://discovery-api.intern-ai.org.cn/v1(qwen3.8-27b 等 10 个模型,5 个支持 视觉)。这是一个 OpenAI 兼容端点,但有 Stock OpenAI 适配器处理不了的地方。 ## 为什么需要专用适配器 **① 思考内容占绝大部分输出,且是独立字段** 实测视觉问答:completion=303,其中 reasoning=295 —— 97% 的 token 预算用在 思考上,只有 8 token 变成正文。 后果是**静默失败**:直接透传上游的 content,调用方看到的是空字符串,看起来 像模型坏了。实测 max_tokens=60 时上游返回 HTTP 200 + content=null + finish_reason="length" —— 一个健康的请求,什么都没产出。 所以思考内容走自己的字段,绝不折进 content:折进去比丢掉更糟,页面会把模型 的内心戏当成答案显示。 **② finish_reason: "length" 配 content: null 是正常的** 不是上游故障。上报真实的 finish_reason 和已产生的部分思考,让调用方能区分 「预算用完了」和「上游挂了」。 **③ 服务端会改写模型名** 请求 qwen3.8-27b,返回 model: "qwen3.8-27b-fp8"。响应里的 model 才是 「谁真的服务了这次请求」,计费按它记账。适配器**不改回去** —— 改回去会让审计 记录撒谎。 ## 踩到并修正的一个真 bug reasoning_tokens 最初放在 token_usage.reasoning_tokens(顶层)。类型检查 通过,然后被丢弃。 internal/gateway/chat.go 只在一个地方读 reasoning: resp.TokenUsage.CompletionTokensDetails.ReasoningTokens 实测证据:模型报 completion=88,网关响应里 reasoning=null。字段在正确的地方、 错误的层级,等于没有。 判据因此改成断言**路径**而不是断言「某处有个数字」,变异把顶层放回去立刻变红。 (顺带发现:deepseek adapter 根本没发这个字段,所以线上 reasoning 计费一直 是 0。那是另一个缺口,本适配器不依赖它被修。) ## 配置时要知道的一件事 远程图片 URL 由**上游自己去拉**,会稳定超时(实测 Wikimedia 图片返回 media_fetch_failed)。图片必须用 data: 内联。 ## 判据(9 条,夹具全是真实响应) - 思考与正文分离,且互不污染 - 截断响应上报 finish_reason 与部分思考 - reasoning_tokens 走正确路径,且是 completion 的子集 - tool_calls 的 arguments 从 JSON 字符串解码 - 流式:思考 chunk 不漏、正文 chunk 不丢、末尾 usage 带 reasoning - 媒体失败带 code(media_fetch_failed),invalid_api_key 不带(免得重复) - 带 tool_calls 的 assistant 轮补 reasoning_content(否则下一轮被拒) - 多模态 content 数组原样透传 变异自证九次全部变红,其中三次是本次专门补的:reasoning 折进 content、 顶层 vs 嵌套路径、设成 0。 ## 隔离实例实测(生产配置 + 真实上游) 非流式 completion=252 reasoning=167 ✓ 透出 流式 26 chunk / 714ms,正文 37 字符,思考 55 字符 ✓ 分离 视觉 base64 图片 → "白色。" reasoning=44 ✓ 无损 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> | 1 month ago | |
test(adapters): 逐适配器两向能力断言 + AdapterIngress 访问器 用户要求:16 个适配器逐个验证两个方向(出向序列化 / 入向解析)重构后都没坏。 - 新增 VM.AdapterIngress(name):暴露适配器认领的入站路径,供运维面与测试查看。 - 新增 TestEveryBundledAdapterTwoDirections:对每个内置适配器断言 1) 上游向 transform_request 存在且可调用; 2) 出向 — 声明了 serialize_response 的必须可调用且产出合法 JSON/SSE, 未声明的确认走内核 OpenAI 兜底(两种都合法); 3) 入向 — 声明了 adapter.ingress 的必须同时实现 deserialize_request (否则内核会让出给空解析器),实现了钩子却没声明 ingress 的也报错 (内核永远不会调用它)。 12 个内置适配器全部通过。 隔离实例实测(19 源 + 16 适配器): - 启动无解密失败/崩溃,16 适配器全加载。 - 入向让出决定性实证:装入认领 /v1/messages 的 zzclaimer 并移除 anthropic 后, 畸形 body 被适配器解析成 model=ZZ-CLAIMER-WAS-HERE,内核据此路由得 404 model_not_found —— 证明内核确实让出(活实例复现,非仅单测)。 - 真实请求两向贯通:deepseek-v4-flash / sensenova-6.8-flash-lite 返回 200 真实响应; 其余源返回上游 502(配额/渠道),说明请求正确到达了各适配器的上游。 | 27 days ago | |
feat(lua): 适配器导入路径冲突校验(R6)——防静默覆盖与路径逃逸 用户要求:导入适配器时必须检查路径冲突,避免静默错误。 之前 UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua: - name 含 "../" 或绝对路径会写到目录外 - name 与内置同名会静默覆盖,加载失败只报 "adapter X missing", 现场与病因分离 实现(internal/lua/adapter_validate.go): - ValidateAdapterName:字符集 ^[a-zA-Z0-9_-]+$,拒绝 /、\、..、绝对路径、空白 - ValidateAdapterPath:文件必须在 adapter_dir 内 - VM.InstallPluginAdapter:插件装内置名 → ErrAdapterNameReserved 拒 - Core.UploadAdapter:在 WriteFile 之前校验(防路径逃逸) - Core.RemoveAdapter:也校验名字(防 os.Remove 越界) 设计取舍:保留名禁止只对插件生效,不对运维生效。 - operator 放 openai.lua 覆盖内置 = 文档化的热覆盖机制,放行 - 第三方插件静默换掉内核自带的协议翻译 = 篡改发行物,拒绝 - 先误做成了全拒,Start() 加载内置适配器直接崩,测试抓住后改对 (TestLoadBundledAdapters 等锁住回归) 测试 12 个:路径穿越/绝对路径/保留段/空白/内置覆盖(允许 vs 插件拒绝)/ 目录外加载/错误点名/热重载不回归。 验证:go test -tags luajit ./... 全绿。 | 28 days ago | |
feat(lua): 适配器导入路径冲突校验(R6)——防静默覆盖与路径逃逸 用户要求:导入适配器时必须检查路径冲突,避免静默错误。 之前 UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua: - name 含 "../" 或绝对路径会写到目录外 - name 与内置同名会静默覆盖,加载失败只报 "adapter X missing", 现场与病因分离 实现(internal/lua/adapter_validate.go): - ValidateAdapterName:字符集 ^[a-zA-Z0-9_-]+$,拒绝 /、\、..、绝对路径、空白 - ValidateAdapterPath:文件必须在 adapter_dir 内 - VM.InstallPluginAdapter:插件装内置名 → ErrAdapterNameReserved 拒 - Core.UploadAdapter:在 WriteFile 之前校验(防路径逃逸) - Core.RemoveAdapter:也校验名字(防 os.Remove 越界) 设计取舍:保留名禁止只对插件生效,不对运维生效。 - operator 放 openai.lua 覆盖内置 = 文档化的热覆盖机制,放行 - 第三方插件静默换掉内核自带的协议翻译 = 篡改发行物,拒绝 - 先误做成了全拒,Start() 加载内置适配器直接崩,测试抓住后改对 (TestLoadBundledAdapters 等锁住回归) 测试 12 个:路径穿越/绝对路径/保留段/空白/内置覆盖(允许 vs 插件拒绝)/ 目录外加载/错误点名/热重载不回归。 验证:go test -tags luajit ./... 全绿。 | 28 days ago | |
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 个)。 | 22 days ago | |
feat(billing): 计费页支持按日/周/月/全部,与统计页同口径 宿主统计页已支持周期,计费页还是终身累计 —— 同一个问题在两个页面重复出现, 且两个页面口径不一致本身就是错。 ## 为什么要补日级维度明细 原来只有 total 和 by_day 带时间维度,by_source/by_model/by_key 是终身累计。 只改 total 的话,页面会显示"今日开销 $0.05",下面三张表还是全量数据 —— 数字对不上,而这正是周期视图要消除的错配。所以在写入时按天折叠成本 (by_day_src/model/key)。成本在写入时就已定价,浏览器只做求和,不重新 定价,显示金额不会与持久化金额漂移。 按天为键的表不随流量增长(一年 365 项/维度),所以不设裁剪。 ## 口径 UTC,与网关 dayKey 和 /api/stats 的周期窗口一致 —— 峰谷小时不会在费用 视图和用量视图落到不同一天。周为 ISO 周(周一起)。降级/未定价没有日级 计数,周期视图下**隐藏**这两张卡而不是显示终身值,那正是要消除的错配。 ## 判据(7 条 + 6 个变异) 判据断言**渲染后的 DOM**,不走 IIFE 内部函数:早先版本加了测试钩子去直接 调 daysInWindow/rescale,结果 harness 里的假 Date 先后两次出问题(整体替换 构造器破坏 toISOString;子类化导致 getUTCDay 返回 NaN),症状都是 "Invalid time value",看起来完全像产品 bug。 变异验证抓到判据本身的缺陷,值得一提: - 捕获「总账不累加」和「周起点改周日」两个变异时**全部判据仍绿**——因为 我断言的是 by_source 表格,而它由维度表独立算出,跟 total 无关。 - 补了 KPI 断言后又漏放「维度折叠不求和」——"页面里存在 60" 太弱, 60 同时出现在 KPI 和 token 列里。改成锚定 srcA 自己的金额单元格 (USD 60.0000),last-day-only 会是 30,才抓得住。 判据报错时先怀疑判据——这次三次都是判据的问题。 | 1 month ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B) 此前两类冲突各有问题: A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默 选一个(装个新插件就可能改变既有路径归属)。 B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。 用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃 或拒绝启动。 ## A 类:插件 HTTP 服务路径冲突 - dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决 返回 409 并点名候选;已裁决按赢家分发。 - route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计 loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。 ## B 类:适配器名冲突 - InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选 入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。 - ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过 reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。 - 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。 ## 持久化 + WebAPI + WebUI - 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回; 重启后插件再装同名适配器自动套用上次裁决。 - WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。 - WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。 ## 测试 - 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、 赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、 自身更新不受影响(token 轮换不走冲突路径)。 - e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409 点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任 保留→裁决 candidate→插件版生效→冲突清空。 - 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。 修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器 重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键 归档,裁决 candidate 后新版本才真正生效。 | 27 days ago | |
perf(plugin): 去掉钩子热路径的 JSON 往返 + 同 stage 跨插件并行 ## 1. 去掉 JSON 往返(快路径) 实测单次 Fire 14.6µs,其中 json.Marshal 4.0 + json.Unmarshal 5.6 = 9.6µs, **67% 花在把 map[string]interface{} 序列化再反序列化**,而紧接着的 pushGoValue 本来就能直接遍历这两种类型。改为按类型直接转换(fastvalue.go), 只对不认识���类型才回落 JSON —— 陌生字段仍然会被送到插件,而不是消失。 快路径与 JSON 路径逐字节等价由 TestFastPathMatchesJSONPath 锁住(8 组载荷, 覆盖 int/uint/float 各宽度、嵌套、slice、map[string]string、未知类型)。 还有一条专门防止「优化悄悄失效」:TestFastPathIsActuallyUsed 用真实的 request_end 载荷断言它确实走快路径。 同一份代码 A/B 实测:JSON 往返 56.4µs → 快路径 35.3µs(省 37%)。 ## 2. 同 stage 跨插件并行 参照 /home/program/TrueAgent 的 StageHost.RunStage: - **快照后释放锁**再并行 —— 它记录过一次自死锁(p.Stop → onExit → ReclaimOwner 要拿 registry 锁,持锁并行即死锁)。这里同理:钩子可能经 admin API 增删插件, 那条路径要拿 ps.mu 写锁,所以并行段内不持任何 ps 锁。 - 每个 goroutine recover。 - 单插件走直连路径,不付 goroutine 代价(生产就是这种配置)。 **与 TrueAgent 不同的一点**:它可以放心并行,因为 handler 只写 ctx.Response 并有 IsResponded() 仲裁;我们的钩子返回 table 会合并进 payload,而 docs/plugins.md 明确承诺「payload 原样传给下一个插件」。所以合并**按插件加载 顺序**执行,结果确定,不依赖调度;代价是钩子之间不再互相可见 —— 这是一处 **契约变化**,已在文档里写明,并说明随核心发布的 billing 从不返回任何值 (代码注释就写着 "nobody downstream would read a return value")。 实测收益(真实二进制,三实例对照,3000 请求): 无插件 1 插件 4 插件 稳态并发32 849 rps 768 (-9.5%) 741 (-12.7%) 突发并发64 1524-1893 1182-1676 1064-1443 4 插件只降 10-20%,而并行前实测 4 插件是 63.8µs vs 单插件 14.6µs(-300%)。 ## ★ 我自己造成的两次性能事故 **① 持久化把热路径拖慢 26 倍。** 最初的快照在钩子路径上做:走 luaValueToGo + json.Marshal + json.Unmarshal 三重转换,每请求 264µs,Fire 从 14.6µs 变成 385µs。 改成 saver 按自己节奏拉取(钩子只标记 dirty,flush 时才快照),385µs → 25.7µs。 **这里还踩了第二次 use-after-free**:让后台 goroutine 去读 Lua 表,vm.Stop() 后 那是已释放内存(SIGSEGV)。安全性现在由「Plugins.Close 等 saver 的最后一次 flush 完成后,调用方才停 VM」保证。 **② 基准被自己的后台写入污染。** 关掉 markDirty 反而测出 36µs、比开着还慢, 方向完全反了 —— 是 saver 每 2 秒写盘混进了计时。加了 DisableStatePersistence 后数据才可信。 ## 判据(11 项,全部变异验证) 快路径等价/确实生效/不别名输入 + 并行与单插件路径合并一致 + 合并顺序确定 + 抛异常的钩子不拖累同伴 + 每插件恰好执行一次 + 并发 Fire 安全 + Fire 期间不持 注册表锁 + 真实 billing 在并行下正常 + 持久化 7 项。 变异:改坏合并顺序 → 红;去掉单插件路径的合并 → 红(3 个既有测试同时抓到)。 ★ 「删掉 recover」这个变异**没有**让判据变红,查下去发现 golua 把 error()、 nil 索引、调用 nil、深递归全部转成 error RETURN,不产生 Go panic —— 那个测试 根本没测到 recover。已改名 TestThrowingHook 并在注释里写明 recover() 当前无法 被 Lua 触达,保留它是为了守 Go 侧。留一个「看起来有覆盖」的断言比没有更糟。 ## 端到端(真实二进制 + 真实 billing) 20 万请求全 200,rps 1870,p99 96ms,RSS 37.9→42MB 有界; 负载停止后四个插件计数**完全一致**(231745),hook_errors 为空; systemctl restart 后 billing 仍是 231745 —— 并行与持久化同时生效。 381 个测试全绿,含 -race。 | 1 month ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
fix(plugins): 插件 Lua 报错不再拖垮网关(生产事故修复) ## 事故 13:37 部署后线上 6 次 SIGSEGV 崩溃循环,8081 完全不可用,用户报大量 connect error。崩溃点固定在 internal/lua/plugins.go:invoke → L.Call → golua StackTrace 里的 lua_getinfo。 ## 根因(不是并发/GC/锁) golua 的 callEx 在**任何** pcall 失败后无条件执行 L.StackTrace(),而 StackTrace 调 lua_getinfo,这个 LuaJIT 构建在栈够深时(带 AUTO 链轨迹的 request_end payload 正好够深)直接段错误。这是 C 层信号,Go 无法 recover, 所以一个插件的脚本错误就能带走整个进程和所有在途请求。 触发错误来自我上一轮加的 billing 日级维度: add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), ...) 三个 bucket( 只对应两个 ),最外层 bucket() 只收到一个参数,k=nil,于是 billing.lua:141 tbl[k] = b 抛 "table index is nil",**每个请求都抛**。 同时还有第二个 bug:中间层用了 bucket()(返回 emptyBucket,含 cost/requests 字段)当作嵌套容器,结构也是错的。改为 dayMap() 返回纯表。 ## 修法 1. billing.lua:修正括号,多层容器改用 dayMap()。 2. **pcall 守卫**(真正的架构修复):在 setupGlobals 里注册 __llmsproxy_call_hook,钩子改为经它调用。 function __llmsproxy_call_hook(fn, payload) local ok, res = pcall(fn, payload) if not ok then return nil, tostring(res) end return res, nil end Lua 侧 pcall 在 golua 看到非零 pcall 状态之前就拦下错误,C 栈回溯路径 永远进不去。错误变成普通返回值 (nil, msg),Go 侧记进 hook_errors 并跳过 ——"插件出错不影响请求转发"这条承诺对脚本错误也终于成立,而不只是对 Go panic。 ## 这同时修掉了那个查了很久的间歇崩溃 同一个机制解释了此前 8/20 复现、却查不出根因的 SIGSEGV(怀疑过 janitor 竞态、 GC、LuaJIT 全局状态、VM 释放时序,全部排除)。实测对比: TestBillingPrecedence 修复前 8/20 崩溃 → 修复后 0/20 并发建 16 个 VM 的探针 修复前 3/3 崩溃 → 修复后 0/6 全量 ./... 连跑 5 次全绿 那些崩溃本来就是一个 Lua 钩子错误在栈深时炸掉 StackTrace,时机随机所以看着 像并发问题。 ## 判据 TestHookThatRaisesDoesNotCrashTheProcess:装一个每请求必崩的插件,连打 50 次, 断言进程存活 + 错误被记录 + 同状态里健康的 billing 插件照常工作。 3 个变异(守卫不 pcall / 守卫名写错 / 守卫未注册)全部被捕获,其中第一个直接 让 SIGSEGV 重现,说明守卫就是唯一防线。 ## 线上验证 往生产插件目录放一个每请求必然报错的插件,连打 30 个真实流式请求: 30× HTTP 200,SIGSEGV 0 次 hook_errors 记录 count=44 且指名 zbroken-test(可观测) billing 照常累计(2999 请求 / $0.5668) 测试插件已移除。 回滚点:/usr/local/bin/llmsproxy.bak-real-<TS>、billing.lua.bak-real-<TS>。 | 1 month ago | |
fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘 生产实证:channels 插件的 auth/state 在主机已授权后仍被拒 (auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination), 而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。 缺陷一(授权对已启动 worker 无效): httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua 闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新** (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回 ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。 修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与 rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker 无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。 缺陷二(授权不持久化): .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。 设置页看不出异常(actions 有持久化,读起来像"都持久化了")。 修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容, 纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权 与 actions 一起写入(支持"只批了主机、没批动作"的行)。 回归测试(internal/lua/plugin_http_test.go): - TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动 worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。 - TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载 后授权保留。 go test ./internal/lua/ 全绿;build/vet 通过。 | 26 days ago | |
fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘 生产实证:channels 插件的 auth/state 在主机已授权后仍被拒 (auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination), 而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。 缺陷一(授权对已启动 worker 无效): httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua 闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新** (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回 ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。 修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与 rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker 无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。 缺陷二(授权不持久化): .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。 设置页看不出异常(actions 有持久化,读起来像"都持久化了")。 修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容, 纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权 与 actions 一起写入(支持"只批了主机、没批动作"的行)。 回归测试(internal/lua/plugin_http_test.go): - TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动 worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。 - TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载 后授权保留。 go test ./internal/lua/ 全绿;build/vet 通过。 | 26 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
refactor(plugins): 统一授权词汇 —— AccessRule ## 问题 同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里: audienceAllows(want, role): "" / "all" → 全开 页面可见性 HTTPHandler.allows(role,key): "" → 仅 admin 注册端点 两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience 藏起来;而端点「忘了声明」绝不能等于对公网敞开。 危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和 「谁能调 /balance」得先学会两套规则,才能回答同一个问题。 ## 改动 新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule, 并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**: - AllowsCapability:空 = 拒绝(端点、函数) - AllowsVisibility:空 = 继承(页面、元素) 语法也统一成一种, ParseAccessRule / Rule 双向: "admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public" @name 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。 ## 抽取时差点弄丢一个真实语义 "all" 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器 不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的 billing、quota 可见性和所有存量 override 都会受影响。 是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益: 两个函数并排住着的时候,没人会想到去动那个共用字符串。 TestAllIsAnAliasForPublic 把它写成新类型的性质,而不是留给碰巧跑到的那 个套件去守着。 ## 顺手加的东西我又撤了 accessRuleFor 第一版把 roles 里写成 @alice 的项重分类成密钥,理由是 「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的 端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、 需要它自己的理由和判据。 ## 判据 七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、 keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、 "all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。 变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认 拒绝、丢掉 "all" 别名。 头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不 匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。 ## 一条被记录下来的意外 TestRoleAndKeyUnionAdmitsBoth 最初断言「同时列出 role 和 key 时两者都放行」。 debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。 这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有 HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」 写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> | 1 month ago | |
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零 Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为 UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。 改掉的具体缺陷: 1. 私有字段泄漏归零(v1 的核心问题) v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, 迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。 现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与 适配器都认这个词: - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成 thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能) - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens} (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法) - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段" 2. Tools/ToolChoice 改 json.RawMessage 原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。 内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何 内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。 3. 删除 MarshalJSON 后门 v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是 UnifiedRequest 上一个正正经经声明的字段。 渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译, 语义变更不被机械改名淹没。 测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传); vm_test.go 两处迁移到中立语义。golden 字节级未漂移。 | 28 days ago | |
feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0 ## 插件管理页(用户要求:权限不要只放 config.yaml) 插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改: - 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人) - 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public) 原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。 ## 修掉的三个真缺陷 1. endpointAccess 读错了 override 的键 SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。 后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。 鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。 2. override 只在内存里 内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失, 日志里没有任何线索。现在落盘到 plugin_dir/.access.json, 插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件, "没有覆盖"用文件不存在来诚实表达。 3. 可见性列表泄漏内部键 端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello" 并可能把它当 audience 存回去。现在按已注册端点集合过滤。 另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的 集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会 显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。 ## 活跃请求:三个视图显示 0 active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。 前端 st.active || 0 于是把"字段缺失"变成了一句自信的"0 活跃", 而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。 现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。 判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。 生产实测 day/week/month/all 均为 4。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> (cherry picked from commit 120ba9e95707bbecb87dd0f3bc44776085653184) (cherry picked from commit 3b28fde22eca314de38699461b6bfc5d89f56065) | 1 month ago | |
refactor(plugins): 统一授权词汇 —— AccessRule ## 问题 同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里: audienceAllows(want, role): "" / "all" → 全开 页面可见性 HTTPHandler.allows(role,key): "" → 仅 admin 注册端点 两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience 藏起来;而端点「忘了声明」绝不能等于对公网敞开。 危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和 「谁能调 /balance」得先学会两套规则,才能回答同一个问题。 ## 改动 新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule, 并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**: - AllowsCapability:空 = 拒绝(端点、函数) - AllowsVisibility:空 = 继承(页面、元素) 语法也统一成一种, ParseAccessRule / Rule 双向: "admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public" @name 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。 ## 抽取时差点弄丢一个真实语义 "all" 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器 不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的 billing、quota 可见性和所有存量 override 都会受影响。 是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益: 两个函数并排住着的时候,没人会想到去动那个共用字符串。 TestAllIsAnAliasForPublic 把它写成新类型的性质,而不是留给碰巧跑到的那 个套件去守着。 ## 顺手加的东西我又撤了 accessRuleFor 第一版把 roles 里写成 @alice 的项重分类成密钥,理由是 「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的 端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、 需要它自己的理由和判据。 ## 判据 七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、 keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、 "all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。 变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认 拒绝、丢掉 "all" 别名。 头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不 匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。 ## 一条被记录下来的意外 TestRoleAndKeyUnionAdmitsBoth 最初断言「同时列出 role 和 key 时两者都放行」。 debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。 这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有 HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」 写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> | 1 month ago | |
feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B) 此前两类冲突各有问题: A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默 选一个(装个新插件就可能改变既有路径归属)。 B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。 用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃 或拒绝启动。 ## A 类:插件 HTTP 服务路径冲突 - dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决 返回 409 并点名候选;已裁决按赢家分发。 - route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计 loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。 ## B 类:适配器名冲突 - InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选 入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。 - ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过 reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。 - 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。 ## 持久化 + WebAPI + WebUI - 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回; 重启后插件再装同名适配器自动套用上次裁决。 - WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。 - WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。 ## 测试 - 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、 赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、 自身更新不受影响(token 轮换不走冲突路径)。 - e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409 点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任 保留→裁决 candidate→插件版生效→冲突清空。 - 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。 修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器 重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键 归档,裁决 candidate 后新版本才真正生效。 | 27 days ago | |
feat(lua): 插件 Lua 侧适配器桥接(R4 Lua 绑定)+ 真二进制适配器接手 e2e 补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。 ## R4 Lua 绑定:__llmsproxy_adapters 用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。 旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。 反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量 是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM), bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的 中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。 顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。 p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压 怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正 长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。 能力(Go 侧强制,非约定): - install(name, source) → (生效名, nil) | (nil, err) - update(name, source) → (true, nil) | (nil, err) 只限自己装的 - remove(name) → (true, nil) | (nil, err) 只限自己装的 - 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件 - 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门) - 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定 **分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器 install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄 改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。 测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里 轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。 ## 真二进制 e2e:适配器接手对外服务 用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内** gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里 发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。 新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的 假上游(该上游对不符形状的请求主动报错,不做宽容接受): - 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳 - 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE] - 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味 压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确** 的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。 | 28 days ago | |
feat(lua): 插件适配器管理桥接(R4-a)—— 安装/改写/删除,支撑 token 轮换 用户需求:插件可安装、运行时修改适配器以支持复杂认证与 token 轮换; 适配器本身不需要持续运行(它是无状态请求级弹性池)。 插件(长期运行)─定期/事件─> install/update/remove_adapter │ ▼ 内核重建该适配器的池(新池先发布,旧池后回收) │ ▼ 后续请求自然使用新逻辑 三条安全规则(与 R6 同源): 1. 路径安全:ValidateAdapterName(无穿越/绝对路径/空白) 2. 不得影子内置:插件不能注册与内置同名适配器(运维仍可自行覆盖) 3. 归属可查:owner 表记录每个适配器由谁安装,于是"插件只能改自己装的" 是可判定的,而非靠约定。没有归属信息时,插件 A 能劫持插件 B 的认证逻辑 而无法被察觉。 修掉两个开发中暴露的真实缺陷: 1. 池替换窗口:LoadAdapterSource 原先在 v.mu 内先 shutdown 旧池再装新池, 窗口期内请求拿到 "pool closed"。改为**新池先发布、旧池后回收**。 2. 并发轮换仍失败:多个 goroutine 同时轮换时,单次重试会连续落在刚被下个 轮换关闭的池上。acquireAcrossSwap 改为按**墙钟**(2s 上限)+1ms 退避 重试新池 —— 轮换是运维的意图,落在微秒窗口里的请求不该因此失败。 上限保证适配器真消失时仍会及时报错而非空转。 3. 声明名 vs 注册键:脚本可声明与注册键不同的 name(插件按自己控制的键注册, 但适配器声明源真正引用的名字)。归属表同时记两个键,删除时一并清理; EffectiveAdapterName 在声明名下也能查到池。 测试:11 个(安装/轮换/影子内置/跨插件窃取/碰运维适配器/路径穿越/删除/ 归属列举/并发 8×20 压力/语法错误安装即拒/声明名上报)。 注:internal/gateway 的变异测试 harness 拒绝在脏工作区运行(设计如此), 故本提交后它才可运行。 | 28 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
fix(billing/quota): 探测预算用 os.clock 是 CPU 时间,12 秒预算从未生效 ## 现象 线上 quota 页面点刷新后卡在「探测中…」,13 秒仍未完成,按钮一直 disabled。 ## 实测根因(不是猜测,是量出来的) 单源探测,隔离实例,逐个测: juziai 16029 ms beizhi 16021 ms localzen 2516 ms frank 2028 ms sensenova 258 ms 而插件自己写着 PROBE_BUDGET_MS = 12000。**修复前**这两个源各耗 **24013 ms** —— 精确等于 4s 超时 × 2 次重试 × 3 个 adapter。预算形同虚设。 ## 原因 exhausted() 用的是 os.clock(),它返回 **CPU 时间**。探测几乎全耗在网络等待上, CPU 几乎不动,于是预算永远不触发。 判据 TestPluginClockDuringNetworkWait 直接量这件事: 3s blocked on HTTP: os.clock advanced 0.001s, os.time advanced 3s **001 秒。** 一个用 CPU 时间写的预算,在 I/O 等待下永远不会响。 这个陷阱和「用忙等测锁竞争」是同一个,只是下沉了一层 —— 而且我第一版判据 正是这么写错的:用自旋循环当「网络等待」,结果 os.clock 走了 1.624s / 2s, 看起来 os.clock 跟的是墙钟,差点得出「不是这个原因」的错误结论。必须用真实 HTTP 请求重测才能拿到真相。 代码里两处同样的写法(第 434 和 466 行),一起改掉。 ## 改完的效果 串行 20 源(/call/ 逐个) 84391 ms fanout 20 源(并行) 16022 ms ⇒ 5.27x 单个死上游从 24s 降到 16s(12s 预算 + 收尾)。 ## 还没解决的 16 秒仍然偏长,因为它等于**最慢那一个源**的预算。这不是并行失效,是并行 之后墙钟的必然下界。要再往下走只有两条路:把预算压到 4~6s,或者给已知死掉 的上游走缓存而不是每次重探。都是参数与策略选择,不是结构缺陷,留给下一步。 | 1 month ago | |
refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃 ## 问题 三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍 「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处, **漏了 Go 那一半不会报错**,字段静默读成未设置: ui = { visibilty = "admin" } -- 一个字母之差 ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分 今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope), 每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。 ## 改动 新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。 字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非 丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。 readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了 readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。 ## 顺带修掉一个真 bug:handler 必须是名字而非函数值 asString 对任何非字符串都 fmt.Sprintf("%v"),所以写成 handler = function(req) ... end 会变成字符串 "function: 0x7f...",被当成 handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。 现在在解码时就拒绝,并同时报出字段名和 path。 ## 更正一个我写了但从未核实的说法 之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals 安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测: setupGlobals 在 vm.go:574,读取函数在 787,顺序相反 json global present when readPlugin* runs: true luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages 真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引 (GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被 SetTop(0) 清掉,两者行为分道扬镳。 一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个 不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。 ## 判据 - 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用 - 正确声明零诊断(否则会被静音,然后什么也抓不到) - 内联函数形式的 handler 被拒并说明原因;handler = "name" 形式不受影响 - decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了 变异自证:strictKeys 短路 → 变红。 已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前 **抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条 在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的, 不假装它是变异可证的。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> | 1 month ago | |
refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃 ## 问题 三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍 「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处, **漏了 Go 那一半不会报错**,字段静默读成未设置: ui = { visibilty = "admin" } -- 一个字母之差 ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分 今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope), 每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。 ## 改动 新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。 字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非 丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。 readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了 readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。 ## 顺带修掉一个真 bug:handler 必须是名字而非函数值 asString 对任何非字符串都 fmt.Sprintf("%v"),所以写成 handler = function(req) ... end 会变成字符串 "function: 0x7f...",被当成 handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。 现在在解码时就拒绝,并同时报出字段名和 path。 ## 更正一个我写了但从未核实的说法 之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals 安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测: setupGlobals 在 vm.go:574,读取函数在 787,顺序相反 json global present when readPlugin* runs: true luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages 真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引 (GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被 SetTop(0) 清掉,两者行为分道扬镳。 一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个 不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。 ## 判据 - 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用 - 正确声明零诊断(否则会被静音,然后什么也抓不到) - 内联函数形式的 handler 被拒并说明原因;handler = "name" 形式不受影响 - decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了 变异自证:strictKeys 短路 → 变红。 已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前 **抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条 在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的, 不假装它是变异可证的。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> | 1 month ago | |
fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘 生产实证:channels 插件的 auth/state 在主机已授权后仍被拒 (auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination), 而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。 缺陷一(授权对已启动 worker 无效): httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua 闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新** (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回 ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。 修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与 rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker 无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。 缺陷二(授权不持久化): .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。 设置页看不出异常(actions 有持久化,读起来像"都持久化了")。 修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容, 纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权 与 actions 一起写入(支持"只批了主机、没批动作"的行)。 回归测试(internal/lua/plugin_http_test.go): - TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动 worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。 - TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载 后授权保留。 go test ./internal/lua/ 全绿;build/vet 通过。 | 26 days ago | |
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零 Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为 UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。 改掉的具体缺陷: 1. 私有字段泄漏归零(v1 的核心问题) v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, 迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。 现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与 适配器都认这个词: - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成 thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能) - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens} (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法) - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段" 2. Tools/ToolChoice 改 json.RawMessage 原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。 内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何 内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。 3. 删除 MarshalJSON 后门 v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是 UnifiedRequest 上一个正正经经声明的字段。 渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译, 语义变更不被机械改名淹没。 测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传); vm_test.go 两处迁移到中立语义。golden 字节级未漂移。 | 28 days ago | |
feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口 问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它 嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且 websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。 ★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里 ①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。 内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理): - plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径 - handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍 ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一) - 鉴权与角色门禁复用现有链路,无旁路 - statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败) - 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost 主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写 - 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道) - 不向插件后端转发 Authorization/Cookie/api_key channels 接入: - 新增 channels/browser/proxy/:session(prefix=true) - 会话代号 = seq-随机8位,防猜到活跃会话 - view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify - 会话过期即回收浏览器栈并返回 404 判据(每条咬真实外部效应): - 静态资源转发 - 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点) - 网关凭据不泄漏(在服务端断言请求头) - 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo) - 跨站 Origin 403 - 未鉴权 401、过期会话 404、非子路径 404 回归:go test -tags luajit ./... 9 包全绿 | 24 days ago | |
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零 Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为 UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。 改掉的具体缺陷: 1. 私有字段泄漏归零(v1 的核心问题) v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, 迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。 现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与 适配器都认这个词: - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成 thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能) - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens} (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法) - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段" 2. Tools/ToolChoice 改 json.RawMessage 原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。 内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何 内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。 3. 删除 MarshalJSON 后门 v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是 UnifiedRequest 上一个正正经经声明的字段。 渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译, 语义变更不被机械改名淹没。 测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传); vm_test.go 两处迁移到中立语义。golden 字节级未漂移。 | 28 days ago | |
fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘 生产实证:channels 插件的 auth/state 在主机已授权后仍被拒 (auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination), 而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。 缺陷一(授权对已启动 worker 无效): httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua 闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新** (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回 ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。 修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与 rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker 无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。 缺陷二(授权不持久化): .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。 设置页看不出异常(actions 有持久化,读起来像"都持久化了")。 修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容, 纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权 与 actions 一起写入(支持"只批了主机、没批动作"的行)。 回归测试(internal/lua/plugin_http_test.go): - TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动 worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。 - TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载 后授权保留。 go test ./internal/lua/ 全绿;build/vet 通过。 | 26 days ago | |
fix(plugins): 三个坏渠道 + 三插件图标 + 计费可见性 用户报障:「检查一下,同时修复坏渠道,还有插件不是可以自己决定注册 哪些渠道吗?你怎么不修一下,还有除了 billing 以外的两个插件都没有正确的 图标,而且另外两个插件计费也不正确」 ## 一、坏渠道(都在插件里修,不动内核) ### 1. zcode —— base_url 吞掉了 endpoint 路径(404) base_url = https://zcode.z.ai/api/v1/zcode-plan/anthropic endpoint = /api/v1/zcode-plan/anthropic/v1/messages 内核 URL = base_url + endpoint(provider.go ChatURL)⇒ 路径拼两遍: .../anthropic/api/v1/zcode-plan/anthropic/v1/messages → 404 page not found。 修:base_url 只留 host,完整路径进 endpoint。与 v2.0.30 「12 平台 base_url 侵占 endpoint」同族。 ### 2. minimax —— 打到 Web 前端(404「简单指令,无限可能」) base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions,那是 Web 前端。 照参考 minimax-product.ts: MINIMAX_INFER_PATH = /mavis/api/v1/llm/v1/messages(**Anthropic Messages**) 实测(2026-10-19): /mavis/api/v1/llm/v1/messages → 401 invalid access token(路径对) 裸 /chat/completions → 404 page not found /mavis/api/v1/chat/completions → 404 修:endpoint 指向真实推理路径 + adapter 改 anthropic(第二层:协议形状)。 修后从 404 变 401(凭据问题,不再是路由问题)。 ### 3. codearts —— 只声明"可续期"却没声明"怎么续" account_refresh 走 refreshCredential(),要求 plat.refresh.url 或 refresh_fn 存在。codearts 只有 accountView 里硬编码的 refreshable=true ⇒ 恒返回「该平台未声明续期方式」,自动续期链空转,日志反复 模型刷新失败 HTTP 400。而 STS security_token 只有 1 小时寿命,过期后 每个请求都是 APIG.0602: the security token has expired。 修法(三层,每层都有独立的坑): a) 加 refresh_fn 钩子。华为云 STS 要 **DPoP 头**(jwk 在凭据里), 声明式 REFRESH_STYLES 拿不到私钥 ⇒ 必须用钩子挂已有的 codeartsRequestToken。 b) **JWK → PEM 重建**。codeartsNewDpop() 返回 {pem,public,private},但 凭据只持久化了 private(JWK),pem 是登录临时文件。而 codeartsDpopSign 走 openssl 命令行要 key.pem 字符串 ⇒ 直接传会报 bad argument #1 to 'write' (string expected, got nil)。 手工组 SEC1 EC PRIVATE KEY 的 DER(INTEGER 1 / OCTET STRING d / [0] OID prime256v1 / [1] BIT STRING 04||X||Y)再 PEM 包装。 c) 绑定放在**文件末尾的绑定循环**(与 claim_fn 同处)。在 PLATFORMS 表里 内联会闭包捕获当时的全局 nil —— 与 trae headers_fn 那次同一坑。 ## 二、插件图标(3 个坏 1 个) billing 有 SVG 但**缺 stroke/fill=currentColor**(深色主题下不可见); channels 声明 icon = ""(侧边栏空白);quota 连 icon 字段都没有。 统一成 inline SVG + stroke="currentColor"(照参考 src/sponsor.js:52 的 图标工厂形状)。 ## 三、判据(4 条,全部做过变异验证) - TestChannelBaseURLDoesNotSwallowEndpoint:base_url 里的路径段不得在 endpoint 重复出现。**走真实加载插件**而非正则读源码 —— PLATFORMS 是 Lua 表,有注释/条件拼装/末尾绑定循环,正则会给出假绿。 - TestMinimaxUsesAnthropicProtocolAndRealInferPath:路径 + 协议双断言。 - TestCodeartsDeclaresRefreshHook:分开报 has_refresh_fn / has_refresh_decl。 只看 refreshable 不够 —— 那是"凭据类型可续",与"平台实现了续期"是两件事。 - TestCodeartsRebuildsPemFromJwkAndSigns:**必须真签名**。DER 结构错、 长度错、base64 换行错都得到"非空但无效"的 PEM,只断言非空等于没验。 - TestB64urlDecodeHandlesLongInput:补一个真实测漏 —— 我的 b64urlDecode 第一版在输出字节后没清高位(缺 buf = buf % (2^nbits)),43 字符的 JWK 字段(32 字节)测出来**完全正确**,于是 PEM 判据在变异后依然全绿。 只有 86 字符的长输入才暴露(double 精度耗尽 → string.char 抛 invalid value)。 这条判据就是为"能跑、能返回、产物错"那类缺陷写的。 - TestEveryPagePluginDeclaresSVGIcon / TestIconUsesCurrentColor: 按**页面插件清单**遍历而不是只覆盖 billing。billing 当年空串的判据只管 它自己,于是 channels 空串、quota 缺字段时全绿 —— 同类缺陷换文件复发 没有任何东西会响。这条测试还当场咬出了 billing 自己缺 currentColor。 变异验证:zcode base_url 回退 ✓ / minimax adapter 回退 ✓ / 删 refresh_fn 绑定 ✓ / 去掉 buf 掩码 ✓ / OID 多套一层 TLV ✓(5/5 全部咬住) ## 四、计费(诊断,未改价) 查清"计费不正确"的根因:prices.models 只配了 **2 条** (deepseek-v4.1-flash + 它的 / 前缀形式),而实际在用 **193 个模型** ⇒ 112006 次请求里 **35672 次(31.8%)成本按 $0 计**。 其中 28203 次集中在 2026-10-05 一天(历史一次性事件); 当前每天 27~137 次,且**渠道模型全部未定价** (Doubao-Seed-2.1-Pro / GLM-5.3-Flash / MiniMax-* / qwen3.6-plus / hy3)。 调研:参考插件从 **models.dev** 拉模型目录(含 cost.input/output)。 实测 models.dev 覆盖我们 139/193 模型(72%),但**不能取中位数** —— 同一模型不同 provider 价差可达数千倍(glm-5.2 从 0 到 1.82), 0 价多是订阅制/coding plan。按 host 匹配 provider 只覆盖 6/24 源。 ⇒ 价格口径需要用户确认,本轮不动价表。 6 个新探针(仅测试/诊断用):__platform_def_probe(平台定义真相)、 __codearts_jwk_pem_probe(PEM 重建 + 真签名)、__b64_probe(解码字节)。 | 20 days ago | |
fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘 生产实证:channels 插件的 auth/state 在主机已授权后仍被拒 (auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination), 而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。 缺陷一(授权对已启动 worker 无效): httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua 闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新** (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回 ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。 修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与 rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker 无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。 缺陷二(授权不持久化): .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。 设置页看不出异常(actions 有持久化,读起来像"都持久化了")。 修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容, 纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权 与 actions 一起写入(支持"只批了主机、没批动作"的行)。 回归测试(internal/lua/plugin_http_test.go): - TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动 worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。 - TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载 后授权保留。 go test ./internal/lua/ 全绿;build/vet 通过。 | 26 days ago | |
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)。 | 23 days ago | |
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)。 | 23 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路 用户要求的四类测试全部落地,且每类都抓到了真问题: ## 0) 测试暴露的真实缺陷(先修后测) **保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前 是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1 形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、 读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。 core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing, 修掉了一个 nil-deref)。 ## 1) 加密解密(internal/lua/secrets_crypto_test.go) - EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700 - PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文 - WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据) - ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确 ## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go) - 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用, 单一事实源) - 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 / expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用 - 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时 两个变异体幸存(落进通用 failed 分支),加 message 后全杀。 这正是变异测试的价值:暴露断言太弱。 - 基准(未变异)必须全过 —— 防止测试自身失效的假阳性 ## 3) 压力(internal/core/channels_stress_test.go) - ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态 - ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失) ## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试) - LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封 - LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、 落盘 seal、master.key 0600 生成 全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。 | 28 days ago | |
feat(lua): 插件 Lua 侧适配器桥接(R4 Lua 绑定)+ 真二进制适配器接手 e2e 补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。 ## R4 Lua 绑定:__llmsproxy_adapters 用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。 旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。 反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量 是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM), bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的 中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。 顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。 p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压 怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正 长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。 能力(Go 侧强制,非约定): - install(name, source) → (生效名, nil) | (nil, err) - update(name, source) → (true, nil) | (nil, err) 只限自己装的 - remove(name) → (true, nil) | (nil, err) 只限自己装的 - 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件 - 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门) - 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定 **分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器 install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄 改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。 测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里 轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。 ## 真二进制 e2e:适配器接手对外服务 用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内** gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里 发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。 新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的 假上游(该上游对不符形状的请求主动报错,不做宽容接受): - 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳 - 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE] - 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味 压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确** 的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。 | 28 days ago | |
fix(plugins): 分片 VM 拿不到插件配置 页面探测走的是 fanout(分片)路径,结果写回走共享 VM。分片 VM 从同一份 源码启动,但没有 state 文件、没有插件身份,所以 PLUGIN.prices(配置,不是 累计状态)在分片里是空的。 对只读累计状态的插件无所谓;对按配置分支的插件就是致命的。OpenDesign 适配器读 prices.control_key,未播种时它对每个主机都得出「未配置」并报 skip,而同一个调用走共享 VM 是好的。也就是说:生产上死、所有调共享 VM 的 测试全绿。 pool 在每次取用时推送配置(不只是首次启动时),并把配置变化也带给池中 复用的 VM;推送是拷贝,分片拿不到共享表的引用。 这条不是在写代码时想到的,是端到端验证时撞出来的:单元测试全过,隔离实例 打真上游时 vela 报「未配置」,而 control_key 明明写进去了。 (cherry picked from commit 65075b6d444e0bedbe0e50c496e4af88ddd514d3) | 1 month ago | |
feat(plugins): 分片调用 —— 让阻塞型导出函数真正并行 ## 问题 quota 页面 20 个源,刷新要 29 秒。页面用 Promise.all 并发发 20 个请求, **看起来是并发的,实际不是**。 Plugins.Call 持 p.mu.Lock(),锁一直持有到 L.Call 返回 —— **包含 Lua 函数 内部的全部网络等待**。20 个请求到了网关后排队,串行累加。 生产实测: 单源探测(无竞争) 1.4 – 3.3 s 20 源 Promise.all 29 s 墙钟 串行理论值 ≈ 29 s ← 与实测吻合 代码里其实已经知道这个问题:quota.lua 第 104 行写着「the whole page shares ONE Lua state under a write lock」,并为此把超时从 10s 压到 4s。但那只把 29s 变成 约 12s,没有解决「刷新不可用」。 而当初改成并发的理由(页面注释)是错的 —— 它假设并发能绕开串行。锁让它退化 回串行了。 ## 不能去掉锁 一个 Lua VM 只有一个栈。两个并发调用会破坏栈。锁是必要的,答案只能是**让每个 并发调用有自己的 VM,跑同一份插件源码**。 先量,再写实现(8 分片 × 300ms 本地 HTTP 端点): 共享 VM,8 次串行 2.407 s 8 个独立 VM 并行 0.304 s → 7.9x 顺带一个测量教训:这个 spike 第一版用**忙等**而不是网络 I/O,报出 2.3x。 那是量到了 12 核机器上的 CPU 争抢(负载 8.95),和锁无关。插件做的是网络等待, spike 就必须做网络等待 —— 和上一条提交里 os.clock 那个错误是同一类。 ## 实现 - callOn(w, fn, args):从 Call 里抽出的「在某 VM 上调一个导出函数」。Call 从 66 行缩到 18 行,仍负责查找、export 白名单和共享 VM 的锁。 - shardPool:私有 VM 池,上限 32。开机约 0.1ms,池化省不了什么但省两次刷新 的重复开销。 - ShardCall(name, fn, argsList):结果与输入**按位置对齐**。 - POST /api/plugins/{name}/fanout/{fn}:{"args": [...]},上限 64 条。 - pluginAPI.fanout():页面一次请求。 - quota.lua 改用 fanout。 ## 分片的硬约束(写进注释,不是靠约定) 分片 VM **不能碰 PLUGIN.state** —— state 在 VM 里,每个分片一份副本,billing 的累加会各分叉,saver 写出去的是随机某一份。分片只能用于只读、幂等的工作。 所以 callOn 既不查 export 白名单(Call 那层才是安全边界)也不落盘。 ## 一个分片失败不拖垮整批 一个上游连不上,不该让 20 张卡片变成一个错误。失败的分片在自己的位置留 null,其余照常返回。**但全部分片失败时报错** —— 那不是「某个上游挂了」, 是结构性问题,报成功会把它藏在满屏红卡片后面。 ## 顺手修掉一个既有缺陷:热重载是坏的 写分片源码来源时,判据直接把它顶了出来:LoadSource 无条件 append,而 findLocked 返回**第一个**匹配。实测: LoadDir 后 1 条 → LoadSource 后 2 条 [0] version=1 [1] version=2 find 返回 version=1 也就是说,**编辑插件并保存看起来什么都没发生** —— 旧版本一直被返回,新版本 躺在旁边不生效。修法是先 Unload 再加载。这不是为分片做的,是分片正好照到了 这条路径,已单独补判据 TestReloadReplacesRatherThanAppends。 ## 判据 七条:并行性(有界而非计时断言,8×200ms 串行是 1.6s,超过 1.0s 即红)、 位置对齐、未导出与未知插件被拒、单个失败不拖批、全失败报错、用已安装源码而 非磁盘、以及热重载替换而非追加。 全量 9 包通过。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> | 1 month ago | |
fix(kernel): Close() 从不调 on_unload —— 孤儿进程与回调文件泄漏 ## 症状 本机积累 **995 个 llmsproxy-loopback.py 进程**(828 个超 24 小时),全是 /tmp/Test*/ 与 .build-work/Test*/ 下的测试残留;生产 /etc/llmsproxy/plugins/ 另有 **18 个孤儿 callback-*.port**(对应 .json 早已消失),且永远清不掉。 ## 真因(两层) 1. **回环回收本是惰性的**:reapLoopback 只在**会话过期**时被 sweepSessions 触发。测试跑完直接退出 ⇒ 会话从未「过期」⇒ 进程常驻。生产会话真会过期, 所以生产不漏进程 —— 但见第 2 条。 2. **★ 根因在内核**:Plugins.Close() 只关 shard 与 saver,**从不调用 on_unload** —— 而 on_unload 只在**单插件 Unload** 时调。于是「进程关闭/ 测试结束」这条路径上,起了 OS 进程的插件永远收不了尾。插件的 plugin.on_unload(回收浏览器栈 + 回环)写得完全正确,只是从不被调用。 这是「两处只修一处」缺陷的又一例:Unload 有、Close 没有。 ## 修法 - 抽 invokeOnUnload(p, name) 助手,**Unload 与 Close 共用同一份实现** (两处各写一遍必然漂移,本次缺陷形状正是如此);Close() 新增 callOnUnloadAll()。 - ★ 顺序:shards.Shutdown() → save.Close()(落盘)→ callOnUnloadAll()。 落盘必须在前 —— saver 的最终写入要读插件的 Lua 表,on_unload 之后 state 已关,读到的就是空,最后一个区间的累积静默丢失(TestCloseFlushesTail 正是 为守住这一点而存在)。我第一版把顺序写反,被该测试当场咬住。 - channels:启动清扫上一进程留下的孤儿 callback-*.port/.json。loopbacks 追踪表只活在内存,进程被强杀时 on_unload 没机会跑,重启后内存表为空 ⇒ reapLoopback 无从知晓 ⇒ 那 18 个文件永久残留。 - channels:补 reap_loopbacks 端点(生产运维也可用:不必等会话过期)。 - channels:__set_loopback_probe 从**覆盖**改为**合并**条目 —— 覆盖会丢掉 真实会话的 pid/port_file,让 reapLoopback 杀不掉接收器,**探针自己成为 泄漏源**(实测:其他测试已归零,只有它留 1 个孤儿)。 ## 判据(均做变异验证) - internal/lua/plugin_unload_on_close_test.go TestPluginsCloseCallsOnUnload 看真实外部效应(插件在 on_unload 写磁盘 marker),而非「钩子被调用过」这类内部信号 TestPluginsCloseSurvivesFailingOnUnload best-effort 契约 - internal/core/channels_orphan_callback_test.go TestPluginLoadSweepsOrphanCallbackFiles 看目录真实状态:孤儿必须消失, 非回调文件(.channels-store.json)不得误删 - 变异均咬住:注释掉 callOnUnloadAll → 报红;清扫改 no-op → 报红。 ## 实测 修复前跑一次起会话的测试批次留 7×N 个残留进程 → 修复后 **0 个**;全量回归 (go test -tags luajit ./... 10 包)后回环/VNC 残留均为 0。 ## 过程中自查出的两处疏漏(已修) 1. 判据顺序又写反一次(同上 TestCloseFlushesTail 咬住)。 2. **两枚变异残渣留在工作区**未还原:internal/gateway/discover.go 里 add(raw, "/models", 0) 被删、classifyAuthStatus 的 || len(body) >= 0 恒真注入。已 git checkout 还原。教训:变异测试必须逐次收尾,别靠记忆。 | 23 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0 ## 插件管理页(用户要求:权限不要只放 config.yaml) 插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改: - 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人) - 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public) 原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。 ## 修掉的三个真缺陷 1. endpointAccess 读错了 override 的键 SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。 后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。 鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。 2. override 只在内存里 内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失, 日志里没有任何线索。现在落盘到 plugin_dir/.access.json, 插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件, "没有覆盖"用文件不存在来诚实表达。 3. 可见性列表泄漏内部键 端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello" 并可能把它当 audience 存回去。现在按已注册端点集合过滤。 另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的 集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会 显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。 ## 活跃请求:三个视图显示 0 active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。 前端 st.active || 0 于是把"字段缺失"变成了一句自信的"0 活跃", 而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。 现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。 判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。 生产实测 day/week/month/all 均为 4。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> (cherry picked from commit 120ba9e95707bbecb87dd0f3bc44776085653184) (cherry picked from commit 3b28fde22eca314de38699461b6bfc5d89f56065) | 1 month ago | |
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零 Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为 UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。 改掉的具体缺陷: 1. 私有字段泄漏归零(v1 的核心问题) v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, 迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。 现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与 适配器都认这个词: - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成 thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能) - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens} (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法) - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段" 2. Tools/ToolChoice 改 json.RawMessage 原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。 内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何 内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。 3. 删除 MarshalJSON 后门 v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是 UnifiedRequest 上一个正正经经声明的字段。 渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译, 语义变更不被机械改名淹没。 测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传); vm_test.go 两处迁移到中立语义。golden 字节级未漂移。 | 28 days ago | |
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)。 | 23 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
fix(quota): 未就绪适配器污染负 memo;被拒凭据被后续 404 掩盖 端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。 一、负 memo 语义混用 负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。 openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目: 1. 未配 control_key → 探测 → 负 memo 2. 操作员写入 key → 探测被退避拦下,adapter 根本没跑 3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时) 不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回 404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。 做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」: - 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip) - 条目带 unready 集合;该 vendor 配置好后条目作废重算 - 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时), 但不退避 —— 退避会拒绝那个本来能成功的探测 二、401 被下一个 adapter 的 404 掩盖 adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份 主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记 得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的 404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作 完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。 判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清: - rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign 排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个 stub(它们读 base 而非 api_base,否则会全部传输失败) - no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有 一道检查),预期已改正 - unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价 探活),已从清单移除 (cherry picked from commit 83f1ec08077e4d1acb1ce92e1a8262e44983f456) | 1 month ago | |
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零 Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为 UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。 改掉的具体缺陷: 1. 私有字段泄漏归零(v1 的核心问题) v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, 迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。 现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与 适配器都认这个词: - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成 thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能) - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens} (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法) - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段" 2. Tools/ToolChoice 改 json.RawMessage 原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。 内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何 内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。 3. 删除 MarshalJSON 后门 v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是 UnifiedRequest 上一个正正经经声明的字段。 渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译, 语义变更不被机械改名淹没。 测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传); vm_test.go 两处迁移到中立语义。golden 字节级未漂移。 | 28 days ago | |
feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B) 此前两类冲突各有问题: A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默 选一个(装个新插件就可能改变既有路径归属)。 B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。 用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃 或拒绝启动。 ## A 类:插件 HTTP 服务路径冲突 - dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决 返回 409 并点名候选;已裁决按赢家分发。 - route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计 loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。 ## B 类:适配器名冲突 - InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选 入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。 - ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过 reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。 - 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。 ## 持久化 + WebAPI + WebUI - 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回; 重启后插件再装同名适配器自动套用上次裁决。 - WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。 - WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。 ## 测试 - 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、 赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、 自身更新不受影响(token 轮换不走冲突路径)。 - e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409 点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任 保留→裁决 candidate→插件版生效→冲突清空。 - 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。 修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器 重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键 归档,裁决 candidate 后新版本才真正生效。 | 27 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
feat(adapters): 入向端点移交适配器 —— adapter.ingress + deserialize_request,内核主动让出 plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk) Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着: /v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified), Anthropic 改协议就得改内核。本次补齐这条对偶。 ## 机制(与出向序列化对称) - 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现 deserialize_request(raw_body) -> unified_json。 - 内核 handleMessages 经新增的 parseViaIngress 判定: - 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成; - 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。 - 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析 (客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。 - 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。 ## 内核改动 - internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取; 新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。 - internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress (让出)→ 未认领时回落内置 anthropicRequest.toUnified。 内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。 ## 适配器改动 - internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages", 新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、 content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。 内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它, Go 侧 anthropicRequest.toUnified 退化为纯兜底。 ## 测试 - gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、 TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、 TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。 - lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致 (system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、 缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。 - 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。 - 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。 | 27 days ago | |
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容 按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据: 1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。 本项目版本号**只由 git describe --tags 推导**(Makefile VERSION), 仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。 2. 逐项核对确认 **main 是 release 的内容严格子集**: - main 独有的**文件**:0 个; - internal/lua/plugins.go / internal/core/kernel_actions.go 等内核文件 main 独有行数:0; - deploy.sh 那 14 行只是 emoji 清理(release 刻意去掉了 emoji); - channels.lua 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。 - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在** (git cherry 逐条列出均为 -,即已合入)。 3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号", 且不会丢任何 main 独有的修复。 本次带回 v2.0.12 的全部内容(37 个提交): - 源串行 + 按模型节流(照 model-gate) - 已失效模型存储 + 远端目录闸门 - 余额分档选号 - 模型能力元数据(models.dev,isFree 权威判定) - **凭据续期修复**(此前恒报 ok:true 却一个请求都不发) - 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正 - 内核级:出站授权持久化重启后不生效、__llmsproxy_plugin_dir ground truth 校验:git diff release/v2.0.x = 0 行;go build -tags luajit ./... 通过。 | 25 days ago | |
chore(adapters): 移除非官方调用方式的适配器,保留官方适配器与模板 移除 4 个靠伪造客户端身份/绕过 WAF 才能工作的适配器,内核不再发行任何 非官方调用方式: - opencodezen / opencode / opencodego:靠 x-opencode-* 客户端指纹冒充官方 客户端;zen 免费池明确要求「free tier can only be used in OpenCode」 - agentrouter:伪造 Origin: qwen.ai + Referer + X-Requested-With 绕过阿里云 WAF 保留 12 个官方适配器(openai/deepseek/anthropic/gemini/github/groq/mistral/ ollama/kimicode/qwen38/sensenova/trae),其中 sensenova/qwen38 实测零伪装特征。 本地保留两处:adapters/_local_archive/(gitignored 非递归子目录,不加载) 与线上 /etc/llmsproxy/adapters/(adapter_dir 权威副本,cp -f 不删文件)。 测试同步:删除仅服务于被移除适配器的 11 个测试函数与 usage_cost_test.go; 改写 2 处借 opencode 之名的 provider 测试为用 openai 的 HTML 错误页压缩能力 验证同一契约(适配器钩子拥有错误格式、原文不外泄)。 验证:go test -tags luajit ./... 全绿;二进制内 x-opencode-client / QwenCode/0.2.0 / qwen.ai 等指纹字符串计数归零。 | 28 days ago | |
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零 Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为 UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。 改掉的具体缺陷: 1. 私有字段泄漏归零(v1 的核心问题) v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, 迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。 现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与 适配器都认这个词: - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成 thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能) - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens} (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法) - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段" 2. Tools/ToolChoice 改 json.RawMessage 原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。 内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何 内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。 3. 删除 MarshalJSON 后门 v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是 UnifiedRequest 上一个正正经经声明的字段。 渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译, 语义变更不被机械改名淹没。 测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传); vm_test.go 两处迁移到中立语义。golden 字节级未漂移。 | 28 days ago | |
fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器 生产实测逐渠道排查(2026-10-19): * qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串; qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test 走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包 {access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。 * trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧), transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报 invalid character 'e'。补 aggregate_solo_sse 聚合。 * buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制 stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。 * provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为 tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。 * opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML); 兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。 opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹 分桶),保留免费层门禁四要素。 * 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的 隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。 * channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号 闪现在 opencode 卡片下的渲染竞态。 * channels request_end hook:挂接丢失的 isModelGone/recordDeadModel —— 真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进 deadModels,不再每轮 404 仍可选中。 (cherry picked from commit 2113bfc125e9cab60595d18af65ab29675e7caef) | 19 days ago | |
fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器 生产实测逐渠道排查(2026-10-19): * qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串; qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test 走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包 {access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。 * trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧), transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报 invalid character 'e'。补 aggregate_solo_sse 聚合。 * buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制 stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。 * provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为 tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。 * opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML); 兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。 opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹 分桶),保留免费层门禁四要素。 * 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的 隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。 * channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号 闪现在 opencode 卡片下的渲染竞态。 * channels request_end hook:挂接丢失的 isModelGone/recordDeadModel —— 真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进 deadModels,不再每轮 404 仍可选中。 (cherry picked from commit 2113bfc125e9cab60595d18af65ab29675e7caef) | 19 days ago | |
fix(plugins): 新内置插件要能落地 —— SeedBundled 改为按文件补齐,deploy.sh 补插件同步 真实事故:channels.lua(多平台账号接入插件)在仓库里、在 2.0 二进制里 (go:embed),生产却完全看不到它 —— /api/plugins 只有 billing/quota/usage。 根因是两处叠加: 1. SeedBundled 只在 plugin_dir **不存在**时才写内置插件。生产 plugin_dir 从 首次安装起就存在,于是后来加入二进制的新插件永远不会落盘。目录存在不等于 内容是最新的 —— 这个假设把"新增内置插件"整条路径堵死了。 改为**按文件补齐**:只在该文件缺失时才 seed,已存在的一律不动(运维自己改 过、或运行时新增的插件必须跨升级存活 —— 与适配器同一条规则)。 2. deploy.sh 完全不处理 plugins(只同步 adapters/)。即使内核修好了,部署脚本 也没有把插件内容与仓库对齐的动作。新增 sync_plugins:备份、只补缺、对已有 文件零改动、末尾逐个校验内嵌插件都在目标目录,摘要里打印插件目录。 测试(internal/lua/vm_test.go): - TestSeedBundledFillsMissingFilesInExistingDir:构造确切事故条件(目录已存在、 只有部分插件),断言缺失的被补齐、运维自己的 billing.lua 不被覆盖、补齐后 channels 确实出现在插件表里。 - TestSeedBundledIdempotent:二次 seed 不覆盖已就位文件。 deploy.sh 通过 bash -n;go test ./internal/lua/ 全绿。 | 27 days ago |
| Files | Last commit | Last update |
|---|---|---|
| 19 days ago | ||
| 19 days ago | ||
| 23 days ago | ||
| 24 days ago | ||
| 23 days ago | ||
| 24 days ago | ||
| 19 days ago | ||
| 25 days ago | ||
| 19 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 27 days ago | ||
| 28 days ago | ||
| 28 days ago | ||
| 22 days ago | ||
| 1 month ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 27 days ago | ||
| 1 month ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 26 days ago | ||
| 26 days ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 28 days ago | ||
| 1 month ago | ||
| 1 month ago | ||
| 27 days ago | ||
| 28 days ago | ||
| 28 days ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 1 month ago | ||
| 1 month ago | ||
| 26 days ago | ||
| 28 days ago | ||
| 24 days ago | ||
| 28 days ago | ||
| 26 days ago | ||
| 20 days ago | ||
| 26 days ago | ||
| 23 days ago | ||
| 23 days ago | ||
| 25 days ago | ||
| 28 days ago | ||
| 28 days ago | ||
| 1 month ago | ||
| 1 month ago | ||
| 23 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 28 days ago | ||
| 23 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 28 days ago | ||
| 27 days ago | ||
| 25 days ago | ||
| 25 days ago | ||
| 27 days ago | ||
| 25 days ago | ||
| 28 days ago | ||
| 28 days ago | ||
| 19 days ago | ||
| 19 days ago | ||
| 27 days ago |