Skip to content

Releases: Practice019/multi2api

multi2api v1.12.1

Choose a tag to compare

@github-actions github-actions released this 02 Nov 11:33

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

Choose a tag to compare

@github-actions github-actions released this 01 Nov 23:38

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

Choose a tag to compare

@Practice019 Practice019 released this 01 Nov 18:13

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。现在直接
    400 unknown_model 并给出用法示例。
  • 严格前缀模式开关 strict_model_prefix(默认 off,热生效):打开后任何裸名一律 400,
    唯一例外是你显式配置过的别名/映射。
  • 目录缓存启动预热 —— 关掉「重启后缓存冷,未知名仍会漏进池子」的窗口(实测:重启后
    不预热直接打未知名,已从 503 变 400)。

⏱ 上游 408 超时:不再冒充「账号池故障」

用户实测(绕开网关直连讯飞,23421 额度账户,同一份 35 token 请求 ×3):
两次 408「响应超时,请重试」(34.2s / 46.2s)、第三次 200 成功(38.9s)
⇒ 讯飞的 408 是服务端拥塞的临时超时,不是仓库注释里假设的「确定性失败」。

  • 408 归 ErrKindServer ⇒ 出口回 502 upstream_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

Choose a tag to compare

@github-actions github-actions released this 24 Oct 18:07

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

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(源文件保留,可回滚)。