Repository navigation
Releases: Practice019/multi2api
Release list
multi2api v1.9.20
v1.9.20
workbuddy-intl(国际版)通了。 17 个账号之前的错误全是假象:账号、token、额度、网络都没问题,
卡在上游 2026-10 新增的渠道校验上。
之前的错误
503 no_healthy_account
"all accounts unavailable (cooling/disabled) —— 17 个账号:0 禁用、0 冷却、0 个在途"
上游真实响应:400 / code 11128 / 「这次调用没通过渠道校验,可能不是官方客户端发出的」
缺的是什么(逐层实测)
| 加了什么 | 结果 |
|---|---|
| 现状(Authorization + X-User-Id + X-Domain) | 400 / 11128 |
+ UA WorkBuddy/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0 + X-IDE-Version: 5.8.4 |
仍 11128 |
| + 会话头族 + 身份头 + 链路头 | 仍 11128,但 msg 变成 first message is not system prompt |
| + 第一条消息用 system | 200 + SSE |
判据与取值来自 aimod-cc/Agent2API 的抓包结论
(core/endpoints.rs 的 EDITION_INTL + providers/workbuddy/adapter.rs 的 chat_headers)。
顺带排除了三个此前被怀疑的方向:TLS 指纹(用 Chrome/Chromium 指纹照样 11128)、
Turing 设备令牌(加与不加一样)、网络/代理(直连 200)。
改了什么
新增 internal/upstream/intl_identity.go:
IsIntlDomain(domain)—— 按域名判(不用 channel 字段:它可能为空,domain 才是真正要打的主机)ApplyIntlChatHeaders—— 一次补齐三层:客户端身份头、会话头族、链路头;
会话 id 每次请求新生成(UUID 带连字符 + 32 位 hex),-Request-ID与-Root-Request-ID同值EnsureSystemFirst—— 首条不是 system/developer 就插一条默认 system;
用map[string]json.RawMessage只重写messages,其它字段(tool_calls/response_format/
stream_options)原样保留
接入两处:ChatHeaders 末尾(按 domain 分支)、ChatStreamWithIP 开头(补 system)。
国内(www.codebuddy.cn / copilot.tencent.com)一个字节都没改 —— 它没有这层校验。
判据
intl_identity_test.go4 条:头齐全、会话 id 形态与唯一性、域名判据、system 首条与不改写既有 bodygo test ./internal/upstream ./internal/server全绿- 真机:起隔离实例(复制那 17 个 intl 账号)→
workbuddy-intl/deepseek-v4.1-flash→
200,内容pong,1552ms
升级提示
改的是服务端出站请求,必须重启进程。重启后 intl 的 17 个账号立刻可用,不需要重新登录或重配。
multi2api v1.9.19
v1.9.19
修一个配好了却拿不到的问题:API key 里给模型配了对外名,/v1/models 却不返回那个名字。
现象
key 里配了 8 个模型、8 条对外名,其中包括
workbuddy/deepseek-v4.1-flash → 006/deepseek-v4.1-flash
但客户端拿到的模型列表里没有 006/…,只回了 7 条 —— 配置在库里是对的(allow 与 model_map 都在),
是列表组装把它吃掉了。
根因:同一个 id 被改名两遍
/v1/models 的组装是:
filterModelsForRequest → appendChannelModels → applyPublicNames
↑ 已经改成对外名 ↑ 又改一遍
第一遍把 workbuddy/… 换成 006/…,第三遍拿着 006/… 再查映射表:
- 精确匹配不命中 —— 映射表里
006/…是键(对外名),不是值(内部名) - 于是掉进裸名兜底:
006/deepseek-v4.1-flash的裸名是deepseek-v4.1-flash,
而 004 / 005 / 006 三个对外名的内部名裸名完全相同 ⇒ 按 map 遍历顺序随机挑一个 - 006 被改成了 004(或 005),随后
seen[pub]去重把整条丢掉
所以配了三条同裸名的对外名,列表只剩两条,而且挂着别人的名字。
修法
publicNameFor 两条:
- 幂等:传进来的 name 本身就是映射表的键(某个对外名)⇒ 原样返回,不再改名
- 裸名兜底只在唯一命中时使用:多个别名共享同一裸名时不猜(返回原值)——
否则同一个 key 两次请求可能看到不同的名字
判据
internal/server/apikey_publicname_test.go(新增 4 条):改前有 3 条红,红的内容与用户现象逐字一致
(实际只回004/…、005/…);修后全绿,含幂等、内部名→自己的对外名、裸名多命中不猜、
以及 filter → applyPublicNames 两遍之后三条一条不少tests/frontend/verify_key_alias_models.js(新增,真机 11 条):起隔离实例 → 建 key(3 个同裸名内部模型 +
3 个对外名)→ 建渠道声明这 3 个模型 → 用 key 请求/v1/models⇒ 恰好004/005/006三条;
再用006/…调/v1/chat/completions⇒ 不是 403(上游 502,说明权限链路是通的)go test ./internal/server/...全绿
升级提示
这次改的是服务端列表组装,必须重启进程才生效(页面走 go:embed,重启后 Ctrl+F5)。
已有的 key 与映射不用重配。
multi2api v1.9.17
v1.9.17
渠道页两处优化(用户批注):删掉「映射」列 + 映射自动生成,不再手工填。
一、列表:删掉「映射」列
原来那格摊的是 a=b, c=d 一长串,既撑出横向滚动条,又看不出重点;而映射现在是自动生成的,
列在列表里已经没有决策价值。7 列 → 6 列(colgroup / thead / 行模板 / 三处 colspan 一起改),
顺手删掉只被这一列用过的 mapText()。
二、表单:映射自动生成
新增「渠道前缀」输入,规则严格按用户要求:
模型栏:qiniuyun/deepseek-v4.1-flash, qiniuyun/kimi-k3-free
前缀: qiniuyun
自动得到:
qiniuyun/deepseek-v4.1-flash → deepseek-v4.1-flash
qiniuyun/kimi-k3-free → kimi-k3-free
- 上游真实名 = 模型名里最后一个
/之后的部分;没有/就取整个名字 - 对外公开名 = 前缀 +
/+ 真实名 - 前缀留空 ⇒ 一条映射都不生成(对外用上游原名)
—— 曾经的写法是"原 id → 裸名",那等于把带斜杠的上游名偷偷改短,而上游认的很可能就是完整 id - 前缀变化强制重算(它是命名空间,改它整张表左列都要换);模型栏变化尊重手改
(你动过映射行就不再自动覆盖),要恢复自动点「按前缀重算」 - 编辑已有渠道时视为"已手改",保护既有映射不被冲掉
判据
tests/frontend/verify_channels_save.js 扩到 20 条断言:列表 6 列且无「映射」、
前缀 + 2 个模型 ⇒ 自动生成 2 行且左右值正确、前缀清空 ⇒ 不再有非空映射行、
保存后后端存下的就是自动生成的那张表。全部 PASS。
另有:channels 两套主题对比度 0 处低于阈值;inline script 语法 6 段通过。
升级提示
页面走 go:embed,升级后 Ctrl+F5;渠道页是 iframe,必要时右键「重新加载框架」。
已有渠道的映射不会被改动 —— 只有你在编辑弹窗里改前缀并点「按前缀重算」才会重写。
multi2api v1.9.16
v1.9.16
修一个用户可见的功能中断:渠道页「新增渠道 → 保存」什么都不发生。
修了什么
控制台里是这句:
Uncaught (in promise) ReferenceError: key is not defined
at save (channels:1595)
webui-channels.html 的 save() 里有这么一行:
if (!editing && !key) { err.textContent = 'API Key 必填(新增时)'; return; }key 从来没有声明过 —— 它本该判上面刚解析出来的 rawKeys 数组。从 T6 首次落地
(29bb9c4)起就这样。
为什么一直没被发现:编辑已有渠道时 !editing 短路为 false,这一行根本不执行;
只有新建渠道才会走到。而 verify_inline_script_syntax 只证明"能被解析"
(语法本身是对的),任何静态断言也看不见"运行期才炸的未定义标识符" ⇒
典型的"点了没反应"。
现在改判 !rawKeys.length,并把来龙去脉写进代码注释。
新增回归判据
tests/frontend/verify_channels_save.js(已注册进 run_frontend_suites 的 PROBES):
自己起隔离实例 → 建管理员 → 打开 /ui/channels → 点「新增渠道」→ 填 name/base/key →
点「保存」,断言 8 条:
- 全程零未捕获异常(
ReferenceError就是这条缺陷的指纹) - 弹窗关闭、空态行被真实数据行替换、名字出现在表格、表单无残留错误
- 后端
/admin/channels确实多了一条
(写这条探针时我自己还踩了个假红:拿"行数 +1"当判据 —— 空池时 tbody 里那一行是
空态行,保存成功后它被真实行替换,行数仍是 1。改成判"空态文案消失"。)
升级提示
页面走 go:embed,升级后 Ctrl+F5 硬刷新;渠道页是 iframe,必要时右键「重新加载框架」。
multi2api v1.9.15
v1.9.15
本版两条主线:前端视觉语言 v3(MAXIMALISM · 霓虹油墨)整体重构,以及可读性判据从"抽样 5 个元素"升级为"全量逐元素"。另修三处前端缺陷。
一、视觉语言 v3 · MAXIMALISM
风格取自 designforai.dev/style/maximalism:奶油纸底 + 墨线 + 五个冲突色 + 硬投影 + 零圆角 + 颗粒纹理 + 跑马灯 + 旋转贴纸。三个页面(控制台 / 登录 / 渠道)统一到同一套语言。
唯一真相 + 一键内联
internal/server/theme-max.css—— 设计系统本体(唯一真相)tools/theme_sync.js—— 内联进三个页面的<style>(幂等;--check只校验,可进 CI)- 三个页面仍是单文件 + go:embed + 零外部依赖;没有引 Google Fonts(用系统超重黑体栈,离线部署不退化)
外壳吵、数据静
- 吵:页头(贴纸 + 跑马灯)/ 侧栏 / 分区标题带 / 统计卡 / 分组头 / 按钮 / 空态 / 提示 / 弹窗
- 静:表格内容区、表单、日志行、代码块(只留墨线,不铺色块、不叠阴影)
理由:这是高密度中后台(账号池一屏 12 个上游、日志整屏)。全套铺满会把状态胶囊与分组头糊成一片,读不出哪一行出错。
字体
- 展示字以 Arial Black 打头(Impact 字面太窄,12~15px 时字母会互相咬住)
- ≤15px 一律退回系统 UI 字,层级改由字重 + 字距表达
- 分组头 gap 10→12px、标题按钮内 6→9px
深色主题
- 统计卡仍是深底 + 亮描边(浅色下才是四色卡片 —— 亮度方向判据使然)
- 边框转霓虹黄、强调色转粉
二、可读性判据升级:抽样 → 全量
新增 tests/frontend/audit_contrast_pages.js(页内逻辑在 lib_contrast_audit.js):
遍历 13 个面板 × 浅/深两套主题的每个可见文本元素,按 alpha 合成真实底色后算 WCAG 比值(正文 4.5 / 大字号 3),不达标就点名 CSS 路径 + 文本 + 前景/底色 + 实测值,非零退出。
它直接抓出了抽样判据够不着的真缺陷:分组头的次要文字在粉底 2.72:1、酸绿底 1.49:1。修完 104 处 → 0 处。
修的四类:
| 问题 | 实测 | 修法 |
|---|---|---|
| 分组头 / 分区标题带的次要文字 | 1.49:1 | 亮底容器内文字一律墨色,层级用字号字重表达 |
| 深色下统计卡的"--"占位 | 1.07:1 | 该处回到 --dim(深底不能用墨字) |
| 侧栏分组头的粉色从未生效 | 徽章 1.07:1 | .navgrouphd 被 .sidebar button{background:transparent} 拍平,提特异性 |
| v2 残留写死色 | #58a6ff 2.24:1 / #0f8a4d 3.9:1 |
属性选择器接到 --acc / --ok(SVG 属性不认 var(),所以不动 JS) |
三、其它修复
- 可用模型:没有可用账号的上游整组隐藏(含内置声明),面板与上游/模型两个下拉一起过滤;
accountCount三态(数字 /null=未知),只在明确 0 时隐藏 —— 否则 manifest 到达前会把全部上游误伤。/v1/models不变(客户端仍需知道上游支持生图)。 - 渠道页操作列:按钮原来带内联
style="padding:0 8px;height:22px"(内联压过样式表)+ 与行底色同色 ⇒ 远看是一整块灰条。现在去掉内联、底色--panel2、2px 墨线 + 相邻 8px + 硬投影。 models_display_gen.js:补「对话密钥」桩,修掉既有ReferenceError: KEY is not defined(34 项失败清零)。
四、验证
go build ./.../go vet ./.../go test ./... -count=1全绿(CI 同口径)contrast_e2e/final_visual/theme_e2e全部通过- 全量对比度巡检:26 组合 0 处低于阈值
- 前端静态套件 23/32:9 条红在 HEAD 基线上逐条复现(dashboard_gen / stats_disk / tokps / gen_ui_manifest / audit_tracked_content / verify_probe_console_loaded 等既有红),非本版引入
升级提示
页面样式走 go:embed,升级后请 Ctrl+F5 硬刷新。渠道页是 iframe,必要时右键「重新加载框架」。
multi2api v1.9.14
v1.9.14
本版两条主线:API Key 粒度的模型对外名映射(新能力),以及把渠道凭据收进 auths/ 让整个目录可搬走。另修了三处交互 bug。
一、API Key 模型对外名映射(新能力)
解决什么问题
API Key 的模型白名单里填的是内部模型名 —— 例如 csg/deepseek-v4.1-flash-1017。直接暴露给调用方既难记,也把自己的渠道结构漏了出去。
现在可以给每把 key 配一张映射表:
内部模型名 对外显示名
csg/deepseek-v4.1-flash-1017 → deepseek-v4-pro
调用方在 /v1/models 里看到 deepseek-v4-pro,发请求也用这个名字;网关收到后换回内部名去上游。
两层映射是正交的
| 层 | 方向 | 解决什么 |
|---|---|---|
渠道 Channel.ModelMap |
公开名 → 上游名 | 怎么打到上游 |
key Key.ModelMap |
对外名 → 内部名 | 对外怎么称呼 |
换一把 key 可以换一套对外名,渠道配置不用动。发给 A 客户一套名字、B 客户另一套,后端是同一批模型。
三个关键设计
① 顺序不能反:先映射、再判白名单
客户端发 deepseek-v4-pro → 映射成 csg/... → 判白名单 ✓
若先判白名单,对外名不在白名单里会被 403 —— 而它恰恰是我们发布出去让人用的名字。
② 映射是增量能力,不是白名单
表里没有的名字原样保留。若把未命中的判成非法,配一张映射表就等于把所有老调用方式打死(表现为“以前能用的模型突然 404”)。
③ body 不用单独改
渠道分支由 forwardViaChannel(model, body) 自己重写 body 的 model 字段,legacy 分支同理。只改 peek.Model 一处,所有消费方自动一致 —— 分两处改必然有一天只改了一处。
界面:行式编辑器
模型对外名(可选)· 左边内部模型名 → 右边对外显示名 [+ 新增一行]
┌──────────────────────────────────────────────────────────────────┐
│ [csg/deepseek-v4.1-flash-1017 ▾] → [deepseek-v4-pro ] [×] │
└──────────────────────────────────────────────────────────────────┘
- 初始 0 行,点「+ 新增一行」才出现
- 左右两侧都能选也能手填(
input+datalist) - 左侧候选 = 已勾选的模型(不列全量目录:选一个没勾的等于配一条永不生效的映射)
二、渠道凭据收进 auths/,整个目录可搬走
之前是分裂的
渠道 API key → data/channels.json
账号凭证 → auths/<provider>/<uid>.json
只把 auths/ 拷到另一台机器,会得到「有账号但没渠道」—— 发请求时不知道该往哪个 base_url 发。
现在
auths/
├── loomy/ workbuddy/ ... 上游账号凭证(不变)
└── channels.json 渠道定义 + API Key + abilities
复制 auths/ 一个文件夹 = 完整迁移。 实测:只带 auths/ + config.json 启动新实例,/v1/models 返回 200 且模型齐全。
⚠ 为什么不只搬 key:base_url / models / model_mapping / priority 与 keys 是同一条路由决策的两半 —— 光有 key 没有 base_url,那个 key 是废的。而 abilities 是从 Channels 派生的索引,必须与它原子地在同一次写盘里。
迁移策略:只复制、不删除
目标已存在 ⇒ 不动(幂等,绝不覆盖你在新位置的修改)
源不存在 ⇒ 不报错(首次部署的正常状态)
父目录不存在 ⇒ 自动建
先写 .tmp 再 rename(中途崩溃不留半截文件)
不删源文件(旧版 exe 回退时仍能读自己的数据)
⚠ 边界:auths 只放上游凭据
控制台账号(accounts.json)、会话(sessions.json)、调用方 API key(apikeys.json)留在 data/ —— 它们不是"拿去换上游额度"的东西(一个是登录我们自己面板的用户,一个是发给下游的钥匙)。
三、三处交互修复
① API Key 启用/禁用只能单向生效
await busyRun(b, async () => {
const on = b.textContent === '启用'; // ← 此时文案已经是 '…'
...
}, { label: '切换', busyText: '…' });busyRun 一进入就把按钮文案改成 busyText,然后才调 fn。所以判断恒为 false ⇒ 永远发送 enabled: false ⇒ 只能禁用、永远启用不了。
改成从列表缓存取状态(const on = !cur.enabled),与「编辑」分支同一数据源。
② 点完 toggle 状态不刷新
toggle 分支漏了 writeInvalidate([EP_APIKEYS])。loadAPIKeys() 走 sharedGet(30s TTL),不丢缓存就拿旧快照重画 —— 表现为“提示说已启用,但那一行状态没变”。save / reset / delete 三处都有这一行,就它没有。
③ 模型映射「设了但重开是空的」/「csg 还是原名」
两个都是「存了但读不出来 / 改了但这个入口没生效」,数据一直在盘上:
3a Store.List() 构造 View 时漏了 ModelMap 字段 ⇒ 前端拿到 undefined ⇒ 回填 0 行。
(同一个坑这个文件踩过:OwnerID 那里就写着「漏掉这一行会让每个成员都看到空表」。)
3b /v1/models 的组装顺序:
data := h.filterModelsForRequest(r, h.modelList()) // ← 这里改名 ✓
data = h.appendChannelModels(r, data) // ← 追加渠道声明的内部名 ✗csg 是渠道不是 provider,不在上游目录里,是后加进来的、绕过了改名。新增 applyPublicNames() 在 append 之后再改一次名。
实测对比:
修复前: 001/DeepSeek-V4.1-Flash + csg/deepseek-v4.1-flash-1017 ← 一个改了一个没改
修复后: 001/DeepSeek-V4.1-Flash + 002/deepseek-v4.1-flash-1017 ✓ 两个都改了
四、删掉「对外模型」页
渠道页原来有两个 tab(渠道 / 对外模型)。「对外模型」回答的是「我配的渠道现在让外面看见什么」—— 那个问题现在由 API Key 的白名单 + 对外名映射回答,两处重复且后者才是对外真相。前端 tab 与后端 GET /admin/channels/public-models 一并删除。
⚠ 不是“把 tab 藏起来”:只删 DOM 却留着 document.getElementById('tab-ch').onclick = ... 会因元素不存在抛 TypeError,整个 script 停摆 ⇒ 渠道列表一条都不加载。所以切换逻辑整段删掉了。
五、安全:挡住凭据包进版本库
仓库根曾出现 auths.zip(98 个条目,含 loomy / codearts / workbuddy 的 token 与 session),只是碰巧没被 git add。
.gitignore 新增 auths.zip / auths-*.zip / auths.tmp / *.zip。
⚠ 为什么不能只靠已有的 auths/ 那条规则:它只挡目录。打包成 auths.zip 放在仓库根时不是“目录下的文件”,规则不匹配 —— 正是这次的形状。已确认仓库里没有已跟踪的 .zip/.tar/.gz,所以 *.zip 不会误伤。
六、验证
go build ./... exit=0
go test ./... ok=39 FAIL=0
go vet ./... exit=0
门禁① 语法 6/6
门禁② hover 读数 4/4
门禁③ toggle 状态 5/5
门禁④ 映射编辑器 10 项
CI verify + 5 平台构建 + 发布资产 全绿
新增守卫
apikey/model_map_test.go 7 条 变参三态 / 规整 / 副本语义
apikey/model_map_wiring_test.go 3 条 ★ List 必须带出 ModelMap;落盘后重开仍在
server/public_name_test.go 4 条 精确匹配 / 裸名兜底 / SplitModel 诊断
tests/frontend/verify_toggle_state_source.js 5 项 ★ 不用 DOM 文本判断业务状态
tests/frontend/verify_model_map_editor.js 10 项 ★ 转置方向 map[对外名] = 内部名
⚠ 本轮三次「假绿」,都记下来
① 检查脚本自己修好了被检查的东西
if (-not $joined.EndsWith("`n")) { $joined += "`n" } # ← 我的检查脚本里这句CI 报 channels.go 未通过 gofmt,真实原因是文件末尾缺换行(\ No newline at end of file)。而我的自检脚本正好补了那个换行 ⇒ 永远绿。
② 路径分隔符导致整轮跳过
用 git diff --name-only 返回的正斜杠路径去 Test-Path,Windows 上对不上就 continue ⇒ 全部跳过 ⇒ 报“全部通过”。
③ 门禁误报 / 误绿
- 门禁① 第一版扫到注释里引用的坏代码(作对照用的
// const on = b.textContent === '启用';)⇒ 改成先剥注释再匹配。 gofmt -l对「缺末尾换行」只报“未格式化”,gofmt -d不显示那是换行问题 —— 只有git diff会写出\ No newline at end of file。
七、已知未做
csg上游的total_tokens是累积值(实测 590 条里 573 次递增,真实总量 0.49M 而按行累加得 228.6M,虚高 463 倍)。这不是我们算错,是上游字段口径问题;要修需要一个累积值检测(三种方案各有代价),等明确方向。-race跑不了:本机无 gcc(CGO_ENABLED=0)。并发结论来自go vet(copylocks / loopclosure 无告警)与代码阅读,没有竞态检测器的证据。- hover 读数的真浏览器验收仍未跑通。CDP 脚本在
tests/frontend/cdp_verify_tooltip.js(改成函数级验证,不依赖登录),尚未实测。
升级方式
五个平台产物任选,SHA256SUMS.txt 校验:
wb2api-server-darwin-amd64.tar.gz
wb2api-server-darwin-arm64.tar.gz
wb2api-server-linux-amd64.tar.gz
wb2api-server-linux-arm64.tar.gz
wb2api-server-windows-amd64.zip
⚠ 前端内嵌在二进制里,升级后请 Ctrl+F5 硬刷新(渠道页是 iframe,必要时对它单独重载)。
⚠ 首次启动会自动把 data/channels.json 复制到 auths/channels.json(源文件保留,可回滚)。
multi2api v1.9.13
v1.9.13
本次两条主线:全项目 review 找到并修掉 6 个真 bug,以及看板 token 口径统一为「总 Token」。
一、全项目 Review(9 轮,6 个真 bug)
按 /review 的五轴(正确性 / 可读性 / 架构 / 安全 / 性能)分维度过了一遍,覆盖 session / server / wire / pool / upstream / admin / account / logbuf / 三个协议转换包 / 前端内联脚本。
⚠ 一个安全问题(建议所有部署升级)
internal/account 的落盘错误被静默吞掉 7 处(a0fcaf0)
saveLocked() 真的会失败(磁盘满、权限不足、Windows 下文件被占用时 os.WriteFile / os.Rename 都会返回错误),而 7 处调用一律 _ = s.saveLocked()。
其中最严重的一处是 Revoke:
用户点了「退出登录」,看到成功提示 —— 而那个 session 在进程重启后会从磁盘恢复、重新可用。
其余后果按调用点不同:AdvanceAuthVersion 没更新、Cleanup 没落盘、TouchLogin 没记。
修法:加 saveOrLog(scene),失败时打一条明确日志(含"内存已生效但磁盘未写入,重启会丢失这次修改"),Revoke 那条额外提示"重启后该 session 会复活,务必排查磁盘/权限"。场景名按调用函数命名,日志里一看就知道是哪条路径。
其余 5 个
| 提交 | 位置 | 症状 | 级别 |
|---|---|---|---|
4223728 |
logbuf/sink.go |
bufio.Scanner 未检查 sc.Err() —— 单行超 1 MiB 时循环正常退出,日志静默截断,HTTP 层拿到一份看起来成功的不完整数据 |
Major |
8e98b9a |
loomy/adminpoints.go |
面板快照的序列化 / 写临时文件 / 改名三处失败全静默,"临时文件写成功了但目标还是旧的",从外面看什么都没发生 | Minor |
8e98b9a |
clientlogin |
归档凭证写入失败被吞 —— 用户以为"这次同步把 N 个号都归档了",实际磁盘上少了几个 | Minor |
147d96a |
anthroconv |
tool_result 合并用的是裸断言 .([]any),靠上一行 hasToolResult 隐式保证类型 —— 逻辑没错,但跨行隐式契约是 panic 的温床(网关 panic = 所有客户端同时断连) |
Nit |
共同点:失败时一个字都不留,用户只会觉得"数据莫名其妙不对"。
判定「不是问题」的(有证据,留档防重复怀疑)
wire 的 SSE 解析 用 bufio.Reader 不是 Scanner ⇒ 无 64KB 单行上限
cache_hit_tokens 异常值 frameUsage 只取最后一帧;矛盾的是旧 tokens 不是 hit
pool.provState 无 delete key 是 provider 名,天然有界
pool 锁内 IO 误报(SetStore 只是赋值)
upstream Body Close 规范;cancelReadCloser 正确
admin cacheRowTrusted 阈值合理:对新数据零误判
wire 字段白名单 24 字段;stream_options 是设计权衡
前端 XSS 119 处 innerHTML,2 处可疑都是 SVG 坐标
前端 setInterval 顶层注册,无重复
前端 panelPolls 四条退出条件齐全(本项目唯一完整的轮询生命周期管理)
完整报告见仓库 tasks/review-full-2026-10-20.md。
二、看板 token 口径统一为「总 Token」
原来面板上并存三个数(总处理量 / 输出 token / 推理 token),彼此定义不同,看谁都像"总数"却互相矛盾。
现在合并为一张「总 Token」,等于 prompt_tokens + completion_tokens,并在所有展示位置统一:
调用统计的三张卡 → 一张「总 Token」
消耗分布副标题 → 总 Token
两个环图标签与取值 → 总 Token
堆叠柱取值 / 分组 → 总 Token
热力图着色 / tooltip → 总 Token
图表纵轴峰值 / 档位阈值 → 总 Token
近 7 天表格 Token 列 → 总 Token
格子详情 / 鼠标读数 → 总 Token
对话测试的用量行 → 总 Token
⚠ tok/s 保留输出 token(改了就错)
速度的物理定义是「生成 N 个字用了 T 秒」。用总 Token 会得出"每秒 40 万 token"这种读数,因为输入里含几万到几十万的缓存上下文。所以性能健康条的吞吐、日志表格的 tok/s 列仍然是输出量。
三、缓存命中率口径统一
原来两处并列,看起来自相矛盾:
近 7 天表格 10/20 → 89% (按**天**累加)
近 24h 趋势 18时 → 43% (按**小时**独立)
算法完全相同,差的只是桶粒度。 但站在两个数前面的读者不会知道这件事。
改法:24h 趋势改为当日累计到该整点,于是最后一个点 == 表格今天的值 == 卡片值。
2026-10-20 04时 当日累计 94.6% (本小时只有 60.2%)
2026-10-20 13时 当日累计 89.5% (本小时只有 45.3%)
2026-10-20 19时 当日累计 89.3% ← 与表格的 89% 一致
鼠标悬停时两个数都给:19:00 累计 89.3% · 本小时 52.9% · 请求 28 · 总 Token 9.8M
⚠ 代价:曲线变单调平滑(累计量只增不减),"某一小时质量差"被摊平 —— 但那个信息用柱子看 Token 量更合适。
四、新增前端语义门禁
修 hover 读数时发现一个坑:语法门禁拦不住语义错。
rd.setAttribute('x', ...);
+ ' 累计 ' + (r.has ? ...) ← 悬空的一元加表达式,求值后丢弃
rd.style.display = 'block'; ← 元素显示了,但内容从没被写+ '...' + x; 是完全合法的 JS,语法检查永远不报。但用户看到的是"鼠标悬浮时没有东西显示"。
新增 tests/frontend/verify_tooltip_assign.js(4 项):
① 读数段落里必须有 `rd.textContent =` 赋值
② 全局扫描:头部行以 ; 结束却还有 + 续行 ⇒ 悬空
③ 读数字段齐全(累计 / 本小时 / 请求 / 总 Token)
④ hover 读数从 host._ctData 取当次数据(不是闭包快照)
反向验证过:人为删掉赋值行,门禁立刻报两项 FAIL。
⚠ 这条守卫我写错了两次判据(第一版误报 44 处、第二版误报 18 处),最终用「把续行往上追到头部行,若头部以 ; 结尾则悬空」这个精确判据才成立。
五、验证状态
go build ./... exit=0
go test ./... exit=0 ok=39 FAIL=0
go vet ./... exit=0 (copylocks / loopclosure 无告警)
门禁① 语法 6/6
门禁② 语义 4/4
CI verify + 5 平台构建 + 发布资产 全绿
⚠ 没做到的(如实说明)
- 没跑
-race—— 本机无 gcc(CGO_ENABLED=0,go test -race报C compiler "gcc" not found)。所有并发结论来自go vet与代码阅读,没有竞态检测器的证据。 - hover 读数没有真浏览器验收 —— 逻辑修复 + 静态门禁都过了,但隔离实例验证卡在登录态(未登录时看板不渲染
#cacheTrend)。已把 CDP 脚本留在tests/frontend/cdp_verify_tooltip.js,补上登录即可跑。 - 没有逐文件通读 359 个生产文件 —— 用的是模式扫描 + 定点取证;
anthroconv/openaicompat/qoderwasm只做了 panic 风险扫描。
六、已知未修(需要方向决策)
csg 上游的 total_tokens 是累积值。
实测 590 条里 573 次递增,第一条 199 → 最后条 493,134,真实总量只有 0.49M,而按行累加得到 228.6M —— 虚高 463 倍。
这不是我们算错,而是上游字段本身的口径问题。要修需要一个累积值检测(记住同上游上一次的值取差值 / 加绝对上限 / 或干脆不信该字段),三种方案各有代价,等明确方向后再做。
升级方式
五个平台产物任选,SHA256SUMS.txt 校验:
wb2api-server-darwin-amd64.tar.gz
wb2api-server-darwin-arm64.tar.gz
wb2api-server-linux-amd64.tar.gz
wb2api-server-linux-arm64.tar.gz
wb2api-server-windows-amd64.zip
⚠ 前端内嵌在二进制里,升级后请 Ctrl+F5 硬刷新浏览器,避免旧 HTML 缓存。
multi2api v1.9.12
会话粘性:四个层面全部修复
会话粘性自 v1.8.0 起从未生效过。这个版本把它修完了 —— 而且修的不止报告里那一个。
层面一:ExtractKey 只认客户端不发的字段
ExtractKey 只看 metadata.conversation_id / conversation_id / metadata.user_id,而 OpenAI 兼容客户端(DSH / Cline / Continue 等绝大多数)从不发送这些字段 —— sessionId 只在 baseUrl 含 api.openai.com 时才写进 prompt_cache_key,x-session-affinity 头只对 OpenRouter 启用。
修法:加第 4 级兜底「对话前缀指纹」—— model + messages 前两条(system 截 512 字符)的 FNV-1a。多轮对话的 messages 是前缀增长的,开头两条永远不变 ⇒ 指纹在整段对话里恒定,这正是缓存亲和需要的不变量,且不需要客户端配合。
三个刻意的取舍:只取前两条(全量会随轮次漂移)、system 截 512(防时间戳)、形态不认识时返回空(宁可轮换也不算出会漂移的键)。
层面二:分配时从全池挑号,把会话绑到别的上游
修完层面一,真机隔离实例实测仍然不粘 —— 每轮换一个号,decision 全是 sticky_unavailable:session_rule。
诊断日志给出确切原因:
[DIAG pickByUID] provider 不匹配: uid=0234c548-... want=loomy got=workbuddy
Router.Resolve 的慢路径自己就分配并绑定账号,而候选池来自 Config.Available,main.go 里赋的是 pool.AvailableUIDs() —— 它等价于 AvailableUIDsFor(DefaultProvider),只返回默认上游的号。
⇒ loomy 的会话被绑到 workbuddy 的号 ⇒ PickByUIDFor("loomy", …) 必然返回 nil ⇒ Unbind ⇒ 下一轮重复 ⇒ 死循环。
修法:Resolve(key, provider) 带上游;Config.AvailableFor(provider) 按上游取候选(pool.AvailableUIDsFor 早就存在,只是没接上)。
层面三:绑定表纯内存,重启即丢
redis_mode: noop 时 Store 是全空实现,而代码注释里写着"防重启丢粘性" —— 设计意图与默认行为背道而驰。
实测代价:msgs=1146 花 5.59 credit、msgs=753 花 1.76 credit,仅仅因为重启(正常请求 0.1~0.3)。
修法:新增 redisstore.LocalFile(data/sticky-bindings.json):
- 原子替换(写
.tmp再 rename;Windows 先 Remove 再 Rename,避免半截 JSON) - 节流 500ms(
SetBind每轮都调,每次落盘会打满盘) - 只存
key→uid,TTL 不落盘(过期仍由lastActive判定) - 带
version字段(将来改结构能识别旧文件,而不是静默读成空表) - 加载失败一律当空表 —— 粘性是优化不是契约
层面四:loomy 上下文超限判成"只换号不罚"
workbuddy 侧的分类器早就覆盖了这些形态,真实缺陷在 loomy:它的分类器只有 7 个 marker,完全没有上下文超限判定,落到 DefaultErrorKind(400) ⇒ ErrKindClient(只换号不罚)。
⇒ 一次"这次请求太长"会把 loomy 的 46 个号全轮一遍(每个号收到的 body 一样长,换到哪都必然超限),最后报成笼统的 503。
修法:加 8 条 context marker(中英双形态)+ isContextExceeded(),判 ErrKindFatalClient —— 不换号、直接结束、透传上游原文。
实测验证
层面一 + 二(隔离实例,78 个真账号:loomy 46 / workbuddy 6 / workbuddy-intl 17 / 其他 9):
修复前(同一段对话 6 轮)
11:42:09 uid=260913075224031074 decision=sticky_unavailable:session_rule
11:42:13 uid=260825100642697603 decision=sticky_unavailable:session_rule
11:42:17 uid=260825101823357254 decision=sticky_unavailable:session_rule
→ 每轮换一个号
修复后(同一段对话 6 轮)
11:46:36 uid=260913075224031074 decision=(空)
11:46:40 uid=260913075224031074 decision=
11:46:43 uid=260913075224031074 decision=
11:46:46 uid=260913075224031074 decision=
11:46:49 uid=260913075224031074 decision=
11:46:53 uid=260913075224031074 decision=
→ 100% 粘在同一账号
层面三(杀进程模拟重启):
第 1 次启动
sticky_sessions = 1
绑定文件 {"version":1,"binds":{"pfx:b1c5ab74":"260924011154958976"}}
杀进程 → 第 2 次启动
sticky_sessions = 1 ← 恢复成功
重启前 uid = 260924011154958976
重启后 uid = 260924011154958976 ← 同一账号
层面四:只有单测。端到端实测没做成 —— 440 KB 请求(275,531 tokens)在 deepseek-v4.1-flash 上直接成功,说明它的窗口远大于报告里的 100,000,没能触发超限。
守卫测试(11 条)
StableAcrossTurns ★ 1/2/3 轮必须同键
DiffersByFirstUser 不同首条 user 不同键
DiffersByModel 不同模型不同键
ExplicitKeyWins 显式键优先
ContentBlocksFlattened 内容块数组折平后同键
UnknownShapeReturnsEmpty 形态不认识返回空
SystemTimestampTailDoesNotDrift system 尾巴时间戳不影响指纹
ResolveRespectsProvider ★ loomy 只拿到 loomy 的号(20 次随机 key)
ResolveStickyAcrossTurns 同一 key 10 轮不换号
ResolveEmptyProviderPoolReturnsFalse 该上游无号时返回 false,不跨界兜底
AvailableForFallsBackToAvailable 没配 AvailableFor 时退回 Available
ContextExceededIsFatalClient ★ 8 种超限形态必须判 FatalClient
ContextExceededNotClient 反证:不能漏判成 Client
FatalDoesNotPunishAccount 防过度修复:不能落进任何"罚账号"类别
InvalidRequestStillFatal 回归:原有 invalid_request 判定不被挤掉
OtherMarkersUnaffected 回归:其余 5 个 marker 不受影响
升级注意
前端 embed 在二进制里,必须重新 build:
git pull
go build -o bin\wb2api-server.exe ./cmd/server重启后验证:/status 的 sticky_sessions 不再是 0;同一会话连发多轮,request-log 里的 uid 应稳定;data/sticky-bindings.json 会出现并在重启后保留绑定。
multi2api v1.9.11
会话粘性修复(issue #2)
会话粘性自 v1.8.0 起从未生效过。这个版本修好了。
缺陷
ExtractKey 只从请求体里找三个字段:
metadata.conversation_id
conversation_id
metadata.user_id
而 OpenAI 兼容客户端(DSH / Cline / Continue 等绝大多数)从不发送这些字段:
sessionId只在baseUrl含api.openai.com时才写进prompt_cache_key(本地网关不满足)x-session-affinity/session_id头只对 OpenRouter 启用,网关侧也不读
⇒ sessKey 恒为空 ⇒ Resolve 从未被调用 ⇒ 粘性层退化为纯随机轮换。
实测后果
/status的sticky_sessions在 6927 个请求下恒为 0- 同会话相邻两轮的账号转移矩阵 ≈ 20/20/20/20/20(5 个号纯随机)
- 上游 prompt cache 按账号隔离 ⇒ 每次换号 = 整段对话重新预填充
- 长对话单笔换号损失实测 2.8 ~ 6.0 credit(正常请求 0.1~0.3)
- 受控对照:不带会话标识 19.8% 缓存命中 vs 带
conversation_id98.7%
修法:第 4 级兜底「对话前缀指纹」
三条显式键都落空时,用 model + messages 前两条(system 截 512 字符)的 FNV-1a 指纹作会话键。
为什么这是对的不变量:多轮对话的 messages 是前缀增长的 —— 第 1 轮 [system, user1],第 2 轮 [system, user1, asst1, user2],前面永远不变 ⇒ 只取前两条,指纹在整段对话里恒定。这正是"缓存亲和"需要的东西,且不需要客户端配合。
三个刻意的取舍:
| 决定 | 理由 |
|---|---|
| 只取前两条 message | 全量哈希会随每轮增长而变,指纹每轮都不同 = 等于没粘性 |
| system 截前 512 字符 | 有的客户端往 system 塞时间戳/请求号,全取会漂移 |
| 形态不认识时返回空 | 宁可退回轮换,也不要算出一个会漂移的键(后者会让表里堆满一次性条目,既占内存又让 GC 白跑) |
7 条守卫测试
StableAcrossTurns ★ 核心:多轮对话指纹必须不变(1/2/3 轮同键)
DiffersByFirstUser 不同首条 user 必须不同键
DiffersByModel 不同模型(模型进了指纹前缀)
ExplicitKeyWins 显式键优先,不被指纹覆盖
ContentBlocksFlattened 内容块数组折平后与纯字符串同键
UnknownShapeReturnsEmpty 形态不认识返回空
SystemTimestampTailDoesNotDrift system 前 512 相同、尾巴时间戳不同 ⇒ 同键
预期效果(issue 报告的实测数据)
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 缓存 miss 率 | 13.3% | 3.1% |
| 成本 / 百万 prompt token | 3.7 credit | 1.2 credit(−68%) |
sticky_sessions |
恒 0 | 14~16 |
| 同会话连续请求同号比例 | ~20% | 100% |
⚠ issue #2 里另外两个观察,本版未处理
① 粘性表纯内存,重启即丢
无 Redis(redis_mode: noop)时,重启后所有长会话被判 assign、重新分配 ⇒ 整段重算。实测两笔:msgs=1146 花 5.59 credit、msgs=753 花 1.76 credit,仅仅因为重启。
建议:无 Redis 时也提供本地文件兜底(data/sticky-bindings.json,原子写 + 启动恢复)。
② context_length_exceeded 仍会轮换全部账号
上游 400 fatal_client("prompt is too long: 100001 tokens > 100000 maximum")实测仍把账号池轮一遍,最后返回笼统的 503 budget_exhausted:global,客户端看不到上游原因。
建议:确定性错误立即失败并透传上游原文。
升级注意
前端 embed 在二进制里,必须重新 build:
git pull
go build -o bin\wb2api-server.exe ./cmd/server重启后验证粘性生效:/status 的 sticky_sessions 应不再是 0;同一会话连发多轮,request-log 里的 uid 应稳定在同一个账号。
multi2api v1.9.9
总 token 算法:三条路径统一
v1.9.8 改了「总 token 自己加(prompt + completion),不信上游 total_tokens」,但那个修复只覆盖了渠道路径。live 实测发现账号池的两条路各有一份同样的错。
这个 bug 一共有三个副本
| 路径 | 原来的代码 | 状态 |
|---|---|---|
| 渠道 | sseUsage.total() |
✓ v1.9.8 修过 |
| 账号池非流式 | st.toks = completionTokens(resp) |
✓ 本版修 |
| 账号池流式 | s.tokens = UsageInt(chunk.Usage["completion_tokens"]) |
✓ 本版修 |
三条路各自解析字段,所以每修一处就漏掉另一处。 现在统一走 totalTokens()。
live 实测证据(重启后)
11:23:38 tokens=823 out=64 hit=640 miss=119 ✓ == 640+119+64
11:23:53 tokens=39967 out=985 hit=26624 miss=12358 ✓ == 26624+12358+985
11:24:09 tokens=3480 out=1007 hit=640 miss=1833 ✓ == 640+1833+1007
重启前(旧进程)仍是 tokens == out 的旧行为 —— 这个对比就是修复生效的证据。
顺带修正 4 处测试判据
夹具的 usage 是 prompt=1 + completion=1:旧算法得 1(正是 bug 行为),新算法得 2。断言跟着改 —— 不改的话测试会一直拦住正确的修复。
⚠ 仍然存在的两个问题(不在本版范围)
1. 历史数据不回填
尝试过,但 cache_hit_tokens 本身不可信:
hit 的 P50 = 175,232 最大 = 1,039,616
最大那条 hit=1,039,616 而该行旧 tokens=980(差 1060 倍)
而上下文窗口不可能有 100 万 token
疑似读到了累积值而非单次值。照这个回填会把 20 亿级错误量灌进日志,所以放弃。
2. cache_hit_tokens 的值可疑
上面那组数字说明缓存命中量的解析或累加有问题。它影响:
- 缓存命中率(四窗已隐藏,暂不影响界面)
- 近 24 小时缓存命中率趋势折线(会显示 >100% 的怪值)
因为新算法用 hit + miss 当总输入,这个可疑字段现在会直接影响 tokens。实测那三条是对的(== hit+miss+out),但前提是 hit/miss 本身可信 —— 这一层还没验。
3. csg 上游不发缓存字段
日志里 hit=0 miss=0,走 total_tokens 回退(tokens=540341 out=1165)。这个回退是合理的,但意味着 csg 的 tokens 仍依赖上游那个可能漏算的 total_tokens。
升级注意
前端 embed 在二进制里,必须重新 build 才能看到界面变化:
git pull
go build -o bin\wb2api-server.exe ./cmd/server