Skip to content

multi2api v1.9.14

Choose a tag to compare

@github-actions github-actions released this 20 Oct 17:05
· 93 commits to master since this release

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