Skip to content

Releases: Practice019/multi2api

multi2api v1.9.20

Choose a tag to compare

@Practice019 Practice019 released this 21 Oct 13:49

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.go 4 条:头齐全、会话 id 形态与唯一性、域名判据、system 首条与不改写既有 body
  • go test ./internal/upstream ./internal/server 全绿
  • 真机:起隔离实例(复制那 17 个 intl 账号)→ workbuddy-intl/deepseek-v4.1-flash →
    200,内容 pong,1552ms

升级提示

改的是服务端出站请求,必须重启进程。重启后 intl 的 17 个账号立刻可用,不需要重新登录或重配。

multi2api v1.9.19

Choose a tag to compare

@Practice019 Practice019 released this 21 Oct 12:45

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 两条:

  1. 幂等:传进来的 name 本身就是映射表的键(某个对外名)⇒ 原样返回,不再改名
  2. 裸名兜底只在唯一命中时使用:多个别名共享同一裸名时不猜(返回原值)——
    否则同一个 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

Choose a tag to compare

@Practice019 Practice019 released this 21 Oct 03:46

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

Choose a tag to compare

@Practice019 Practice019 released this 21 Oct 02:13

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

Choose a tag to compare

@Practice019 Practice019 released this 20 Oct 21:31

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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 17:05

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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 11:35

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 平台构建 + 发布资产 全绿

⚠ 没做到的(如实说明)

  1. 没跑 -race —— 本机无 gcc(CGO_ENABLED=0,go test -race 报 C compiler "gcc" not found)。所有并发结论来自 go vet 与代码阅读,没有竞态检测器的证据。
  2. hover 读数没有真浏览器验收 —— 逻辑修复 + 静态门禁都过了,但隔离实例验证卡在登录态(未登录时看板不渲染 #cacheTrend)。已把 CDP 脚本留在 tests/frontend/cdp_verify_tooltip.js,补上登录即可跑。
  3. 没有逐文件通读 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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 04:08

会话粘性:四个层面全部修复

会话粘性自 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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 03:37

会话粘性修复(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_id 98.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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 03:27

总 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