Repository navigation
Releases: Practice019/multi2api
Release list
multi2api v1.12.1
v1.12.1 — 2026-11-02
格式遵循 Keep a Changelog,版本号遵循 语义化版本。
本次为补丁发布:无破坏性变更,可直接升级。
修掉一个会让服务自己把机器拖垮的缺陷 —— 上游账号凭证失效后,
后台额度核查会陷入无限重试,单容器长期吃满一个 CPU 核并高速刷日志。
修复
额度核查在账号凭证失效后无限重试,可致整机卡顿
- 此前行为:某个上游账号的凭证失效后,服务会持续重试该账号,
容器 CPU 长期接近 100%(单核),日志以约 900 MB/小时增长。
持续数小时可写满系统盘,表现为整机变卡;重启只能缓解,随后复发。 - 现在行为:失败账号进入退避窗口,不再被反复重试,负载恢复正常。
- 额外加固:核查循环加了轮次上限 —— 即使将来再出现同类遗漏,
也只会有限次重试并留下一条日志,不会无声地无限循环。
影响范围:使用 loomy 上游的部署。其他上游不受影响。
变更
- 同一账号的同类失败日志,现在最多每 5 分钟记录一条(此前每次失败都记录)。
wb2api容器的日志上限设为 50 MB × 3 个文件(此前无上限,可无限增长)。
验证
| 检查 | 结果 |
|---|---|
go build ./... / go vet ./... |
通过 |
go test -short ./... |
48 个包全部通过,0 失败 |
| 容器 CPU | 90 ~ 124% → 3.3% |
| 日志速率 | 1187 行/秒 → 0.6 行/秒 |
| 全机空闲率 | 11.8% → 90.6% |
| 服务健康检查 | healthy 153/163 |
已知问题
账号池 104 个 loomy 账号中有 3 个(2.9%)的上游凭证已失效,需在管理台
逐个重新登录(手机号 + 短信验证码)。这 3 个账号会周期性产生提示日志,
但已受上述限流与退避约束,不影响服务运行。
⚠️ 勘误:本版此前的说明写的是"8 个",属误报。原因是日志中的账号标识
只截取 uid 前 8 位,把同一天创建的一批账号显示成了同一个标签,据此误判了数量。
已用全量实测更正为 3 个。
升级
无破坏性变更,替换二进制后重启即可。
- Docker:
docker compose up -d --build - 裸机:替换
wb2api-server后重启进程
部署建议(不随本 release 分发,按需自行添加):给容器加日志上限
(本版已改docker-compose.yml),并可加一条定期巡检记录 CPU / 日志大小,
以便下次异常能自己留下证据,而不是靠人发现"机器卡了"。
multi2api v1.12.0
v1.12.0
修掉用户实测的两个缺陷:loomy 免费额度优先失效、破甲覆盖调用方 system。
loomy「免费额度优先」失效 —— 真根因是跨日
用户实测:「loomy 不是说优先消耗免费额度、等所有账号都用完才动永久的吗?我发现还是会消耗永久额度。」
根因不是最初猜的漏扣减,而是跨自然日清空池子后没有任何重填机制:
resetIfNewDay 跨日清空 have/zero/free
-> StartFreePoolFill 只在启动时跑一次
-> 跨日后池子永久为空
-> candidate_tier 的 empty 分支(len(have)==0)成立
-> out[uid] = tierFree(全体放开)
-> 所有号同档 -> 加权随机 -> 烧永久额度
证据链:启动填充 22:17 是 44/44 ✓,但清扫日志显示池子 2->3->4->5->6(极小),
而实测 37/44 个号免费额度仍是 ~4990,日志里零额度查询失败(排除了"查询失败"假设)。
三层修复:
- A 跨日保留 have 名单(只清 zero/free/checkedAt)=> 这些号落 tierUnsure,
不构成强偏好,但不会被当成"池子空了可以烧永久" - B 跨日后第一次选号触发后台串行重填(200ms 间隔、recover、幂等、每日一次)
注意:选号路径本身不发上游请求,"读池子纯内存"契约未破 - C empty 判据加 && hasRefilledToday(now) —— 本日还没重填完成时绝不宣布"可以烧永久"
防查询洪水三道闸:同锁内"检查+占位"(并发 10 次只有 1 次抢到,有测试)/串行 + 每号 200ms
(上游实测 12 条并发就 502)/每个自然日最多一次,空闲部署零请求。
次因:工具路径消耗免费额度但不记账
points_consumed 的扣减原来只挂在 ChatStream 的 usage tap 上,而生图(execGenerateImage)
与搜索(execWebSearch)也产生该字段,却只塞进 Artifact 给客户端看、从不告诉池子。
已补,并加源码级守卫 TestEveryPointsConsumedSiteFeedsPool:枚举包内所有解析点,
断言每处都有配套 noteConsumed => 将来新增计费路径会变红而不是静默漏扣。
破甲不再覆盖调用方 system / 不再吃掉用户文本
用户报告:中转站注入问题报告.md(6724 token 固定前缀、覆盖 system、JSON Schema 连续 7 次返回 []、耗时 1s->180s)。
真机 A/B/C 实测(system = "你是麦麦,10 字以内"):
| 模式 | prompt_tokens | 回答 |
|---|---|---|
| append(推荐) | 7165 | 「在的,怎么啦?」 遵守人格 + 6 字 |
| replace | 7134 | 「在。Little Code Sauce 在线…代码、逆向、渗透、外挂…」 人格被覆盖、40+ 字 |
| off | 683 | 「在的」 |
- 默认值本来就是 off(不是 replace)—— 用户看到的 replace 是设置页自选的。
- 设置页已改:off/append 标注推荐、replace 标为高风险,hint 写进实测后果。
- 修掉一个真 bug:stripArmorBlock 的截断分支会"从开标记切到结尾",
当客户端 system 里恰好出现该标记字样时会把用户正文整段吃掉(数据损坏、不可恢复)。
判据从"标记后面有多长"改成"标记是否在行首",不确定时保留。
验证
- go build ./... exit 0
- go test -short ./... => 48 包 ok / 0 FAIL
- 前端结构探针 => 全部通过
- 新增测试 12 条;两条变异验证先红后绿:
- 把「保留 have」改回「清空」=> 报"池子会变空=>empty 分支全体放开=>烧永久额度"
- 把两处 noteConsumed 换成 _ = n => 守卫报"解析了 points_consumed 但从不调用 noteConsumed"
真机验证:连发 8 个内容不同的请求 => 8 个不同的号(正常轮询);
8 个内容相同的请求 => 1 个号(会话粘性,设计意图,为保上游 prompt cache 命中率)。
已知边界
- 真机"跨日"要等次日 00:00 才能直接观察日志「跨日重填完成 N/N」,本次靠单元测试 + 状态一致性。
- 会话粘性依赖 PrefixKey(model + 前两条消息前 512 字符的 FNV-1a 哈希)。
单轮且内容重复的请求会粘同一个号 —— 符合"用完再换"规则,但换得慢。
顺带说明
release.yml 现在从仓库根的 RELEASE_NOTES.md 读说明,不再用那句硬编码的
"自动构建产物(Windows / Linux / macOS…)"模板(v1.10.0 起正文只有 58 字符就是这么来的)。
文件缺失时回退模板,而不是静默发一个空 Release。
multi2api v1.11.0
v1.11.0
破甲(dsh-purge 行为 1:1 移植)+ 模型名校验 + 网关错误语义修复。
这一版的主线是「让上游限制不再假装成我们的故障」:破甲覆盖全部上游、模型名必须带前缀、
408 不再被误报成账号池故障。
🛡 破甲(从 dsh-purge 逐字移植)
- 注入载荷逐字移植 ——
internal/prompt/armor-payload.md是上游项目实际注入正文的
逐字节副本(21194 字符 / 226 行)。前一版我按自己的理解重写过(只覆盖 5.8%),
已推翻,并加了保真测试钉住「载荷 ≥20000 字节 + 11 个分节标记 + 块内原样包含整份载荷」。 - 覆盖所有上游(此前只有 workbuddy)—— 注入抽成共享层,接在核心出站边界的三个点
(账号池漏斗 / 工具循环 / 渠道),实测prompt_tokens从 655 → 7134(loomy)、
5880(workbuddy-intl)、6295(cline)、6148(raccoon)。 - 三档模式:off / append(保留客户端人格后追加)/ replace(整体替换),默认 off。
- 拒绝自动重试 —— 照
lib/refusal-retry.js:缓冲前 400 字符判定、命中即丢弃本次输出、
把重试话术追加到系统提示尾部重发一次、审计留痕。默认关。
非流式路径是我们的扩展(原件只挂流式),已在代码里标注。 - 审计(照
lib/audit.js)——data/audit.jsonl:JSONL、1 MiB 轮转、保留 2 份、
保留字段不可被覆盖、写失败不致命。
🏷 模型名:目录只出现带上游前缀的名字
- 渠道声明模型但没写映射时,公开名改为
<渠道名>/<上游名>(此前直接暴露上游原名)。 - 未知 / 无前缀模型名前置校验:以前
model=definitely-not-a-model-xyz会掉进账号池
挨个试 6 个账号、白烧上游配额,最后回一个把锅甩给账号的 503。现在直接
400unknown_model并给出用法示例。 - 严格前缀模式开关
strict_model_prefix(默认 off,热生效):打开后任何裸名一律 400,
唯一例外是你显式配置过的别名/映射。 - 目录缓存启动预热 —— 关掉「重启后缓存冷,未知名仍会漏进池子」的窗口(实测:重启后
不预热直接打未知名,已从 503 变 400)。
⏱ 上游 408 超时:不再冒充「账号池故障」
用户实测(绕开网关直连讯飞,23421 额度账户,同一份 35 token 请求 ×3):
两次 408「响应超时,请重试」(34.2s / 46.2s)、第三次 200 成功(38.9s)
⇒ 讯飞的 408 是服务端拥塞的临时超时,不是仓库注释里假设的「确定性失败」。
- 408 归
ErrKindServer⇒ 出口回 502upstream_error(此前是
503 invalid_request … all accounts unavailable (cooling/disabled)—— 而 0 个号在冷却)。 - 换号重试的时间预算 90 秒:只判「已花时间」会跑出 144 秒(第三次在边界上被放过),
已改成「已花时间 + 上次耗时 < 预算」。 - 失败原因进请求日志:
attempts=3 spentMs=144223 last=loomy server 408。
🔄 自动续期统一到「寿命过半」
所有能算寿命的上游都按 生命周期 50% 续期,去掉「全量续」与固定窗口。
实测生效:workbuddy 新鲜号被正确跳过(续期 0,跳过 6(其中寿命未过半 6)),
cline/raccoon 该刷的照刷(续期 1/2,失败 0)。
🧰 诊断工具
cmd/auditreport—— 审计报告 CLI(零依赖):重试次数 / outcome 分布 / 命中规则分布 /
检测器复核 / 窗口过滤 / 请求侧统计;覆盖轮转文件.1/.2;退出码 0/2/1。
不编数:本仓请求日志不落回答正文,所以「拒绝率」显式打印「无法计算 + 原因」。
🐛 这一版修掉的几个真 bug
prompt_armor_retry重启后被静默复位(双重失效:normalize 没有解析行 +
main.go构造状态机时没传值)。这是「两处存储」家族在本仓的第三次。
已加源码级守卫:凡适配器能写的破甲键,normalize 必须有解析赋值。replace模式下重试话术被第二次注入冲掉(话术被挤到块前或直接消失,重试等于没重试)。- 注入块幂等(标记必须包住整块,否则重复注入会叠加)。
升级提示
- 破甲两个开关默认关闭。要用:设置页「出站提示词」组,或
PUT /admin/settings {"prompt_armor_mode":"append","prompt_armor_retry":true}。 - 打开
strict_model_prefix前先确认客户端都写了前缀 —— 裸名(含auto)会 400。 - monkeycode 的 session 是 30 天硬过期,上游不下发滚动续期(实测响应头无
Set-Cookie),
到期只能重新登录/重注册。
♻️ 凭据续期:邮箱 + 密码自动换新 session(monkeycode)
- 新增
internal/monkeycode/renew.go:password-login + PoW 换新 session,成功后写回磁盘与本地池;
失败不动旧 session(不会把还能用的凭据弄坏)。 - 新增到期锚点
Auth.SessionRenewedAt:「凭据有效期」按真续期时刻 + 30 天显示,
不再拿入库时间估算(后者会把刚续过的号误报成"即将过期")。 - 注册机导出行支持
email/password;新增端点POST /admin/monkeycode/renew
(按 uid 批量续期,回执renewed/skipped/failed/remaining,绝不回显 session;
含"24 小时内已续则跳过",防连点刷爆上游)。
📉 额度刷新:从"每 3 分钟刷全部"改成"算得出来的靠算"
用户原话:「3 分钟太快了,我觉得没有必要」。
| 上游 | 策略 |
|---|---|
有回执(loomy / qoder / qodercn) |
默认不刷;只在 24 小时或成功调用增量 ≥200 次时校正一次(偏差需要周期性基准,否则无界累积) |
| 没回执(其余) | 按活跃度分层:热 10 分钟 / 温 30 分钟 / 冷 6 小时 |
- 首轮(还没账的号)全量刷一次 —— 不会让面板长期空白。
- 死号跳过:连续 3 轮刷完仍拿不到数据(
HasData==false)⇒ 暂停该号刷新,一次成功即恢复;
跳过的号不计入失败率(不误触发降速);日志只在状态变化时打一行。 - 请求量:102 个号由 2040 请求/小时 → 约 106 请求/小时(≈ −95%)。
- 选号侧新增可选判据
pool.SetQuotaStalenessChecker("额度严重过期就别选它"):
默认不启用;启用后仍保证三条安全:nil 即零行为变化、候选被排空时回退(不会变成 503)、
全过期只告警一次。
🧹 loomy 面板预热
- 修掉幽灵 uid 让"过期数"虚高、待办永不归零(每 5 秒空转)的问题;预热前顺带修剪已删账号的缓存行。
- 日志不再输出空的
首个错误=""。
🌐 浏览器登录:不再残留进程与临时 profile
- Windows 上
Process.Kill()不级联(TerminateProcess只杀那一个),且主进程一死父子关系即断
⇒ 连taskkill /T也枚举不到孤儿。现在杀之前先把整棵树挂进 Job Object
(killtree_windows.go,仅用 stdlib);Unix 侧Setpgid+kill(-pgid)。实测同场景 10 → 0。 - 临时 profile 清理与进程清理解耦:先等进程真退出(最多 10 秒)再删,删不掉打 warn
—— 此前静默失败曾攒出 110 个目录 / 约 18 GB。 - 新增启动时陈旧 profile 巡检(清超过 1 小时的),作为"进程被杀时谁也走不到清理"的止血。
- 兜底清理不再依赖
closure是否已赋值。
🔧 其它
- 「换号重试」加时间预算:修掉"一次请求 40 秒膨胀成 150 秒"。
408归入ErrKindServer,不再误报成invalid_request/all accounts unavailable。- 国际站代理改为可选出口覆盖("必须走代理"是错的前提:多数环境直连即可)。
- 发布流程:Release 正文改为读取本文件(此前是一句 58 字符模板话,等于丢掉"这版改了什么")。
🔐 安全
- 忽略
_backup-*/(内含真实凭证副本),并清掉测试文件里的真实凭证(改用.invalid域与固定假值)。 - ⚠ 历史遗留:更早的提交里曾出现过一处真实密码 / 会话(已推送)。
建议尽快改密并作废旧 session;历史清理(filter-repo+ force-push)由维护者决定。
multi2api v1.10.0
v1.10.0
分组系统成型 + 健壮性四波 + MonkeyCode 上游完整接入。
v1.9.20 之后积压了 66 个提交,这一版一次性收口。
一、分组系统(P1 → P6,主线)
把「哪些模型能用」从 API Key 上移到分组,Key 只引用分组。
这消除了一直存在的双真相来源:同一个 Key 既在 models 白名单里列,
又在分组里配,两处不一致时没人知道该信谁。
| 阶段 | 内容 |
|---|---|
| P1 | groupstore 分组存储(实体)+ 管理接口 + 装配接线 |
| P2 | apikey.Groups + 判定链(分组优先,白名单兜底兼容存量) |
| P3 | 目录按分组收窄 + 存量 Models 迁移成分组(幂等) |
| P4 | 渠道多分组(EffectiveGroups + 笛卡尔积);P4.5 同档失败换渠道 |
| P5 | 删分组级联清理 Key 引用 + 一致性检查 + 一键清理端点 |
| P6 | 控制台分组管理页 /ui/groups |
别名从 Key 移到分组
对外模型名(别名)跟着分组走,不再挂在 Key 上。支持一对多:
同一个对外名可以合并多个上游,调用时轮询、失败自动换下一个。
轮询只在有健康账号的上游之间进行;某个上游整体没号时自动跳过,
不会把请求打到一个必然失败的上游上。
前端
- 对外名改为模型驱动:每个已选模型自动一行,左右默认相同、可改、不可删
- 上游筛选条(抽共享层
model-picker.js,699 个模型按 15 个上游筛) - API Key 弹窗只选分组,不再选模型
- 移除分组表格的「模型」列(内容太多把行撑高,编辑弹窗里看)
二、健壮性(Wave1 → Wave4,对标 new-api)
| Wave | 内容 |
|---|---|
| 1 | 错误码体系 + 错误脱敏 + 超时集中配置 |
| 2 | 重试决策(对齐 decideTaskRetry)+ 账号自动禁用阈值 |
| 3 | 参数覆盖 + 账号延迟指标(P50/P95)+ base_url SSRF 校验 |
| 4 | 错误脱敏集成 + 自动禁用(含恢复通道)+ 参数覆盖 + 工具循环脱敏 |
配套的两个池子修复:
- 429 软冷却喂上游级熔断 —— 上游整体限流时立刻失败,不再逐个号白试
- 软冷却不再兜底 —— 没健康号立刻失败,不再白试 1.5 秒;429 也不再触发自动禁用
三、MonkeyCode 上游(第十三个上游)
本仓第一个需要请求签名的上游,也是第一个国内/国际双站按账号自动分流的上游。
签名 = v1=HMAC-SHA256(signing_secret, instructions)
签名对象是请求里的静态提示词(Responses 的 instructions / Anthropic 的 system),
与 input / tools / model 全都无关。
双协议路由:模型分属 openai_responses(/v1/responses)与 anthropic(/v1/messages),
走错是 404「路由不存在」而不是 403 —— 很容易误判成账号问题。
双站网络前提相反:国内站必须直连、国际站必须走代理。配错的表现都报 EOF,
核心显示「账号不健康」,极难定位。
本版补齐的三件事
① 定时签到(本次最重要的修复)
签到驱动早就实现好了:按钮能点、历史会写、手动签到成功,
但永远不会自动跑。根因是调度器发现任务的方式是构造期遍历注册表、
对每个 Provider 做 gateway.ExtOf[gateway.JobExt] 类型断言,
没实现这个接口的上游被静默跳过(scheduler/job.go 里那行是 continue,不是 error)。
补上 Jobs() 后,调度器任务表里出现:
monkeycode-checkin provider=monkeycode
这和 lobsterai 当初漏写 Log 是同一类问题:契约的一环没人守。
② 注册机导出 → 批量导入链路
认 JSONL / 注册机字段名,导入后自动灌池,首次对话自动补签。
③ 新增「今日额度」列
对标注册机的「今日 Token」,显示剩余/上限并按已用比例上色。
四、其他修复
- errmask:URL 里的方括号 IPv6 打码不幂等 —— 同一串字打两次结果不同
- webui:恢复被误删的三个映射函数(
ReferenceError打断 IIFE 导致所有按钮失效)+ 加未定义函数守门 - anthroconv:不传
max_tokens时补模型输出上限(原来写死 4096,长输出被静默截断) - channels:删除渠道时级联清理 API Key 里的模型引用(白名单 + 别名映射)
- 脱敏:普通 API Key 按别名回显,
owned_by/ 模型 ID / 响应model/id/ 私有 usage 五项全抹
五、CI 转绿(附带修的)
CI 的 Check formatting 已经红了很久,阻塞发版。两个原因叠在一起:
① 口径不一致导致真问题被埋
CI 在 Linux(LF)跑 gofmt -l .,本机是 Windows + core.autocrlf=true(CRLF)。
本地跑会报 402 个文件(几乎全仓),全是假阳性 —— 于是没人认真看。
② gofmt 规则在 1.22 之后变了,而且方向相反
本地: go1.27.0
CI: go1.22.12(workflows 里 go-version: '1.22')
go.mod: go 1.22.5
go1.22 要: "x-anthropic-billing-header", // 注释
go1.27 要: "x-anthropic-billing-header", // 注释
1.27 把注释也对齐到最长元素,1.22 不做。所以用本地 gofmt 修 CI 的格式检查永远修不完 ——
修好了反而把文件改成 CI 不认的形态。
最终用 go1.22.5 的 gofmt(go toolchain 缓存里有一份)把 46 个文件修完,
全仓 854 个 .go 在 LF 口径下 gofmt -l 零输出。
六、验证
go build ./... ✓
go vet ./... ✓
go mod tidy 无变化 ✓
gofmt -l(go1.22,LF,854 个 .go) ✓ 零未格式化
go test ./... -short ✓ 全绿
CI(a012f4b) ✓ success
真机验证:起隔离实例(端口 7866,复制真实配置)→ 查 /admin/ui/manifest:
manifest.jobs 共 20 条,含
monkeycode-checkin provider=monkeycode
升级提示
- 需要重启进程:改的是服务端行为(签到任务注册、分组判定链)
- 分组数据自动迁移:存量
apikey.Models会幂等迁移成分组,升级时自动保留旧格式备份 - 签到立即生效:重启后 monkeycode 账号开始按 30 分钟间隔自动签到,不需要重新登录
- 分组页面在
/ui/groups
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(源文件保留,可回滚)。