dsh-modelscope-pool 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/cordis.patch.yml +4 -0
- package/lib/catalog.js +105 -0
- package/lib/index.js +965 -0
- package/lib/pool.js +336 -0
- package/lib/provider.js +227 -0
- package/lib/proxy.js +426 -0
- package/lib/quota.js +131 -0
- package/lib/route.js +104 -0
- package/package.json +107 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yizutt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,303 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-modelscope-pool
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
|
|
7
|
+
**魔搭(ModelScope)多 Key 聚合网关**:把十几个、几十个魔搭 API Key 拼成一个 OpenAI 兼容接口。当前 Key 遇到限流、额度不足、无权限或令牌失效时,**自动无感换下一个 Key**,模型始终不变。
|
|
8
|
+
|
|
9
|
+
同时它是 [DSH](https://github.com/deepseek-ai/deepseek-harness) / dsh-go 的原生插件:在手机 App 里直接看额度、管 Key、选模型,不需要另开网页。
|
|
10
|
+
|
|
11
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 目录
|
|
16
|
+
|
|
17
|
+
- [特性](#特性)
|
|
18
|
+
- [它是怎么工作的](#它是怎么工作的)
|
|
19
|
+
- [安装](#安装)
|
|
20
|
+
- [配置](#配置)
|
|
21
|
+
- [界面](#界面)
|
|
22
|
+
- [HTTP 接口](#http-接口)
|
|
23
|
+
- [接入其它客户端](#接入其它客户端)
|
|
24
|
+
- [常见问题](#常见问题)
|
|
25
|
+
- [安全说明](#安全说明)
|
|
26
|
+
- [开发](#开发)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 特性
|
|
32
|
+
|
|
33
|
+
### 无感路由:只换 Key,不换模型
|
|
34
|
+
|
|
35
|
+
你请求 `DeepSeek-V4.1-Flash-1017`,拿到的永远是 `DeepSeek-V4.1-Flash-1017`。网关只是在背后换一个还能用的 Key 重发。
|
|
36
|
+
|
|
37
|
+
| 上游返回 | 网关的判断 | 处理方式 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `401` / 令牌无效 | 这个 Key 废了 | 冷却 5 分钟后重试 |
|
|
40
|
+
| `402` / `429` + 额度文案 | 额度真的用完了 | 停用到**北京时间次日 00:01**自动恢复 |
|
|
41
|
+
| `429` 限流(无额度文案) | 只是被限流 | 冷却 5 分钟,并对这个 Key 反复刷新额度直到刷出数字 |
|
|
42
|
+
| `403` / `404` / `400` 无此模型 | 这个 Key 没权限,不是坏了 | 只拉黑「Key × 模型」组合 5 分钟,Key 本身照用 |
|
|
43
|
+
| 网络错误 / 超时 | 线路问题 | 换备用线路或下一个 Key |
|
|
44
|
+
|
|
45
|
+
### 自动识别国际版 / 国内版
|
|
46
|
+
|
|
47
|
+
`.ai` 和 `.cn` 是两套独立的账号体系,令牌不通用。导入 Key 时会用**扣费为零**的额度接口(`/magicubes/balance`)探测它属于哪个站点,并记住结果:
|
|
48
|
+
|
|
49
|
+
- 国际版 Key → 走 `https://api-inference.modelscope.ai/v1`
|
|
50
|
+
- 国内版 Key → 走 `https://api-inference.modelscope.cn/v1`
|
|
51
|
+
|
|
52
|
+
**不工作**的做法:用 `/v1/models` 验令牌——那个接口是公开的,任何(甚至伪造的)令牌都返回 200。
|
|
53
|
+
|
|
54
|
+
### 模型表实时合并两个站点
|
|
55
|
+
|
|
56
|
+
面板和 `/v1/models` 的模型来自两个站点实时拉取的推理目录,按「去掉厂商前缀后的名字」合并:
|
|
57
|
+
|
|
58
|
+
| 展示名 | 国际版真实 id | 国内版真实 id |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `GLM-4.7-Flash` | `zai-org/GLM-4.7-Flash` | `ZhipuAI/GLM-4.7-Flash` |
|
|
61
|
+
| `DeepSeek-V4.1-Flash-1017` | `deepseek-ai/DeepSeek-V4.1-Flash-1017` | — |
|
|
62
|
+
|
|
63
|
+
同一个模型只显示一行;请求时按当前 Key 所属站点自动换成那边的真实 id。版本号(`.1`、`-0813`、`-1017`)是模型身份的一部分,**原样保留**。
|
|
64
|
+
|
|
65
|
+
### 额度保护
|
|
66
|
+
|
|
67
|
+
- 剩余魔粒(ModelScope 的额度单位)**低于 2** 时,Key 自动停用到次日 00:01,避免无谓的 `429`。
|
|
68
|
+
- 每 5 分钟批量刷新一次额度;单个 Key 出现 `429` 时,按 0/5/10/20/40/60/90/120 秒退避单独重刷,直到刷出数字为止。
|
|
69
|
+
- 手动禁用(`manual`)的 Key 不会被自动恢复流程放开,必须手动点「恢复」。
|
|
70
|
+
|
|
71
|
+
### 单 Key 全生命周期管理
|
|
72
|
+
|
|
73
|
+
禁用 / 停用到次日 / 恢复 / 删除,逐条可见:令牌掩码、所属站点、剩余额度、状态、最近一次失败原因、已用次数。
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 它是怎么工作的
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
客户端(DSH / Cherry Studio / 任何 OpenAI 客户端)
|
|
81
|
+
│ Authorization: Bearer <网关自己的占位密钥>
|
|
82
|
+
▼
|
|
83
|
+
127.0.0.1:8899 ──────────────────────────────────────
|
|
84
|
+
/v1/chat/completions
|
|
85
|
+
│
|
|
86
|
+
├─ 模型名解析(简化名 / 真实 id 都认)
|
|
87
|
+
├─ 按 Key 所属站点算真实上游 id
|
|
88
|
+
├─ 选一个可用 Key(跳过冷却、停用、被手动禁用的)
|
|
89
|
+
▼
|
|
90
|
+
https://api-inference.modelscope.ai|cn/v1
|
|
91
|
+
│
|
|
92
|
+
├─ 200 → 透传;响应头标注用了哪个 Key
|
|
93
|
+
└─ 失败 → 分类(换 Key / 停用到次日 / 拉黑组合)→ 重试
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
一次请求最多把池里的 Key 都轮一遍(「这个 Key 没开通该模型」的扫描另有 24 次上限),客户端只会看到一次结果。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 安装
|
|
101
|
+
|
|
102
|
+
### 前置条件
|
|
103
|
+
|
|
104
|
+
- Node.js ≥ 20
|
|
105
|
+
- 一个魔搭账号和它的 API Key(`ms-` 开头),在 <https://modelscope.cn/my/myaccesstoken> 或 <https://modelscope.ai> 生成
|
|
106
|
+
|
|
107
|
+
### 作为 dsh-go 原生插件安装
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# 1. 把仓库放进 dsh-go 的本地插件目录
|
|
111
|
+
# 目录名必须叫 dsh-modelscope-pool(与 package.json 的 name 一致)
|
|
112
|
+
mkdir -p "$DSH_HOME/dsh-go/plugins"
|
|
113
|
+
cd "$DSH_HOME/dsh-go/plugins"
|
|
114
|
+
git clone https://github.com/Yizutt/dsh-modelscope-pool.git dsh-modelscope-pool
|
|
115
|
+
|
|
116
|
+
# 2. 装进当前 profile
|
|
117
|
+
dsh plugin --profile web add "$DSH_HOME/dsh-go/plugins/dsh-modelscope-pool"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
装好后重启 App,或让插件重新加载。网关会在 `127.0.0.1:8899` 起来。
|
|
121
|
+
|
|
122
|
+
### 只想用网关、不装 App
|
|
123
|
+
|
|
124
|
+
插件本身就是一个 Cordis bundle(`package.json` 里的 `dsh.bundle.patch` 指向 `cordis.patch.yml`),所以也可以直接挂到 Harness 的 profile 里:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
dsh plugin --profile web add /path/to/dsh-modelscope-pool
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 配置
|
|
133
|
+
|
|
134
|
+
首次启动会自动在 `$DSH_HOME/modelscope-pool/` 下创建:
|
|
135
|
+
|
|
136
|
+
| 文件 | 内容 | 权限 |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `config.json` | 端口、冷却时长、上游地址 | `0600` |
|
|
139
|
+
| `keys.json` | 你的所有 Key | `0600` |
|
|
140
|
+
| `cooldowns.json` | 冷却与停用状态 | `0600` |
|
|
141
|
+
|
|
142
|
+
`config.json` 的全部字段(都有默认值,通常不用手改):
|
|
143
|
+
|
|
144
|
+
```jsonc
|
|
145
|
+
{
|
|
146
|
+
"port": 8899, // 网关监听端口
|
|
147
|
+
"cooldownMs": 300000, // 临时失败的冷却时长(5 分钟)
|
|
148
|
+
"timeoutMs": 120000, // 单次上游请求超时
|
|
149
|
+
"upstream": "https://api-inference.modelscope.ai/v1", // 主线路
|
|
150
|
+
"upstreams": [], // 备用线路(主线路连不上时自动换)
|
|
151
|
+
"apiKey": "", // 网关自己的占位密钥,留空则不校验
|
|
152
|
+
"model": "" // 默认模型;留空则用请求里带的
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
> 改了 `config.json` 不需要重启插件,网关切页时会重新读取。
|
|
157
|
+
|
|
158
|
+
**Key 只能通过界面或 `keys.json` 添加**,不会写进任何日志、视图或响应头(响应头里只有 `k4` 这样的内部编号)。
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 界面
|
|
163
|
+
|
|
164
|
+
插件在 dsh-go 的对话界面加了入口(输入框上方两个按钮 + 一条常驻状态条),另外注册了三个原生面板:
|
|
165
|
+
|
|
166
|
+
| 面板 | 位置 | 内容 |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 可用 / 冷却 / 魔粒三个数字、Key 状态扇形图、Key 列表(分页)、刷新额度、测试全部、全部恢复、添加 Key |
|
|
169
|
+
| **Key 详情** | 侧栏 / 功能页 | 点列表任意一行进来:完整令牌、站点、额度明细、状态、失败原因,以及禁用 / 停用 / 恢复 / 删除(删除需连点两次确认) |
|
|
170
|
+
| **魔搭模型** | 对话标签 / 侧栏 / 功能页 | 按「国际版 / 国内版 / 双站」分组的模型列表,点一下设为默认,底部可重新拉取 |
|
|
171
|
+
|
|
172
|
+
输入框上方还有一条常驻状态条,显示池里可用 Key 的数量。
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## HTTP 接口
|
|
177
|
+
|
|
178
|
+
全部为 OpenAI 兼容格式。
|
|
179
|
+
|
|
180
|
+
### `POST /v1/chat/completions`
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
curl http://127.0.0.1:8899/v1/chat/completions \
|
|
184
|
+
-H "Authorization: Bearer <config.apiKey,留空可省略>" \
|
|
185
|
+
-H "Content-Type: application/json" \
|
|
186
|
+
-d '{"model":"DeepSeek-V4.1-Flash-1017","messages":[{"role":"user","content":"hi"}],"stream":true}'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
成功响应的头里会标注实际用了什么:
|
|
190
|
+
|
|
191
|
+
| 响应头 | 含义 |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `x-pool-key` | 内部 Key 编号,如 `k4` |
|
|
194
|
+
| `x-pool-region` | `ai`(国际版)或 `cn`(国内版) |
|
|
195
|
+
| `x-pool-model` | 你请求的模型(简化名) |
|
|
196
|
+
| `x-pool-upstream-model` | 实际发给上游的真实 id |
|
|
197
|
+
|
|
198
|
+
### 其它接口
|
|
199
|
+
|
|
200
|
+
| 方法 | 路径 | 说明 |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `GET` | `/healthz` | 存活检查 |
|
|
203
|
+
| `GET` | `/v1/models` | 合并后的模型列表(OpenAI 格式) |
|
|
204
|
+
| `GET` | `/v1/pool/status` | 每个 Key 的状态、额度、冷却原因 |
|
|
205
|
+
| `GET` | `/v1/pool/provider` | 当前注册的供应商信息 |
|
|
206
|
+
| `GET` | `/v1/pool/models` | 模型目录(含两个站点的真实 id) |
|
|
207
|
+
| `GET` | `/v1/pool/balance` | 汇总额度 |
|
|
208
|
+
| `POST` | `/v1/pool/model` | 设置默认模型 |
|
|
209
|
+
|
|
210
|
+
### 错误码
|
|
211
|
+
|
|
212
|
+
网关自己产生的错误沿用 OpenAI 的结构:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{ "error": { "message": "池里还没有 Key", "type": "no_keys" } }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
| `type` | HTTP | 含义 |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| `no_keys` | 503 | 池里一个 Key 都没有 |
|
|
221
|
+
| `no_available_key` | 429 | Key 都在冷却/停用,等一会或加新 Key |
|
|
222
|
+
| `model_unavailable` | 403 | 所有 Key 都拿不到这个模型(多半没开通),带 `retry-after` |
|
|
223
|
+
| `upstream_unreachable` | 502 | 所有线路都连不上上游 |
|
|
224
|
+
| `pool_error` | 4xx/5xx | 其它网关侧错误 |
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 接入其它客户端
|
|
229
|
+
|
|
230
|
+
任何支持 OpenAI 接口的客户端都能用,填三样:
|
|
231
|
+
|
|
232
|
+
| 字段 | 值 |
|
|
233
|
+
|---|---|
|
|
234
|
+
| Base URL | `http://127.0.0.1:8899/v1` |
|
|
235
|
+
| API Key | `config.json` 里的 `apiKey`;留空随便填 |
|
|
236
|
+
| Model | 面板里「魔搭模型」中挑一个 |
|
|
237
|
+
|
|
238
|
+
dsh-go 里更进一步:这个插件会把自己注册成一个叫 **魔搭 Key 池** 的供应商,模型直接出现在 App 的「模型」列表里,选一次就不用再管。
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 常见问题
|
|
243
|
+
|
|
244
|
+
**导入后一堆 Key 显示「令牌无效」?**
|
|
245
|
+
先看是哪个站点。`.ai` 令牌发到 `.cn`(或反过来)一定返回 401。面板里会显示每个 Key 识别出的站点;如果识别错了,把 Key 删掉重新导入。另外别同时探测太多 Key,并发过高会让上游返回 429,被误判成无效。
|
|
246
|
+
|
|
247
|
+
**`Qwen-Ambassador/*`、`MiniMax/MiniMax-M3` 总是失败?**
|
|
248
|
+
这些模型需要额外开通权限,普通令牌访问会得到 403。这是正常的,网关会直接返回 `model_unavailable`,不会把 Key 一起停掉。
|
|
249
|
+
|
|
250
|
+
**额度明明还有,却一直 `429`?**
|
|
251
|
+
魔搭的 `429` 有两种:`insufficient balance`(真没钱)和 `Throttling`(限流)。网关靠报错文案区分。如果文案变了,改 `lib/proxy.js` 里的 `quotaLike()` 正则。
|
|
252
|
+
|
|
253
|
+
**Key 一直不自动恢复?**
|
|
254
|
+
看看是不是被「手动禁用」了(`manual` 标记)。手动禁用不会被自动恢复流程放开,需要点「恢复」。
|
|
255
|
+
|
|
256
|
+
**端口被占用?**
|
|
257
|
+
改 `config.json` 的 `port`,同时把客户端和供应商配置里的地址一起改掉。
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 安全说明
|
|
262
|
+
|
|
263
|
+
- Key **只**保存在本机 `$DSH_HOME/modelscope-pool/keys.json`,文件权限 `0600`。
|
|
264
|
+
- 网络接口只监听 `127.0.0.1`,不对外暴露。
|
|
265
|
+
- 日志、面板视图、响应头里只有内部编号(`k4`)和掩码(`ms-…add2`),完整令牌只在你主动点开「Key 详情」时显示。
|
|
266
|
+
- 仓库里不含任何真实令牌;`.gitignore` 已排除所有运行时数据文件。
|
|
267
|
+
- 请不要把你的 `keys.json`、`.env` 或截图里的完整令牌提交到仓库或贴进 issue。
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 开发
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
lib/
|
|
275
|
+
index.js 插件入口:面板、动作、状态条、供应商注册
|
|
276
|
+
proxy.js OpenAI 兼容网关:路由、重试、错误分类
|
|
277
|
+
pool.js Key 池:持久化、冷却、额度下限、手动禁用
|
|
278
|
+
quota.js 额度查询:.ai / .cn 双站点、退避、并发控制
|
|
279
|
+
catalog.js 模型目录:两站拉取、按简化名合并、跨站 id 映射
|
|
280
|
+
provider.js 注册成 DSH 模型供应商(含展示名、分组)
|
|
281
|
+
route.js 「Key × 模型」黑名单与线路健康度
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
约定:
|
|
285
|
+
|
|
286
|
+
- 只用 Node 内置模块,没有第三方运行时依赖。
|
|
287
|
+
- 所有界面文案走 `{ zh, en }`。
|
|
288
|
+
- 改完跑一遍语法检查和插件检查器:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
node --check lib/index.js
|
|
292
|
+
node "$DSH_HOME/skills/dsh-go-plugin/kit/bin/check.mjs" . --load
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
欢迎 issue 和 PR。提交前请确认没有把令牌、`keys.json` 或日志带进来。
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
[MIT](LICENSE) © 2026 Yizutt
|
|
302
|
+
|
|
303
|
+
本项目与 ModelScope(魔搭)官方无关,只是本机的客户端侧聚合工具。使用前请自行阅读并遵守 [ModelScope 服务条款](https://modelscope.cn)。
|
package/cordis.patch.yml
ADDED
package/lib/catalog.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// 模型目录:国际版(api-inference.modelscope.ai)与国内版(api-inference.modelscope.cn)
|
|
2
|
+
// 各有一份模型表,两边取并集并按「简化名」合并:同一个模型在两个站点的 id 可能不同
|
|
3
|
+
// (例如 zai-org/GLM-4.7-Flash 与 ZhipuAI/GLM-4.7-Flash),合并后只出现一行,
|
|
4
|
+
// 真正请求时按 Key 所属站点换成那边的真实 id。
|
|
5
|
+
const REGION_LABEL = { ai: '国际版', cn: '国内版' }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 简化名只去掉厂商前缀,其它字符原样保留:
|
|
9
|
+
* `deepseek-ai/DeepSeek-V4.1-Flash-1017` → `DeepSeek-V4.1-Flash-1017`。
|
|
10
|
+
* 版本号里的 .1 是模型身份的一部分,绝不能吞掉。
|
|
11
|
+
*/
|
|
12
|
+
export function simplifyModelId(id) {
|
|
13
|
+
const text = String(id ?? '').trim()
|
|
14
|
+
return text.includes('/') ? text.slice(text.lastIndexOf('/') + 1) : text
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createCatalog({ regions, preferred = ['ai', 'cn'], ttlMs = 300_000, timeoutMs = 15_000 } = {}) {
|
|
18
|
+
let cache = { at: 0, entries: [] }
|
|
19
|
+
|
|
20
|
+
const sites = () =>
|
|
21
|
+
preferred
|
|
22
|
+
.map((key) => ({ region: key, base: String(regions?.[key]?.inference ?? '').replace(/\/+$/, '') }))
|
|
23
|
+
.filter((row) => row.base.length > 0)
|
|
24
|
+
|
|
25
|
+
const fetchOne = async (base) => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${base}/models`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) })
|
|
28
|
+
const body = await res.json().catch(() => undefined)
|
|
29
|
+
const rows = Array.isArray(body?.data) ? body.data : Array.isArray(body) ? body : []
|
|
30
|
+
return rows
|
|
31
|
+
.map((row) => String(row?.id ?? '').trim())
|
|
32
|
+
.filter((id) => id.length > 0 && id.length <= 120 && !id.includes(' '))
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get entries() {
|
|
40
|
+
return cache.entries
|
|
41
|
+
},
|
|
42
|
+
get ids() {
|
|
43
|
+
return cache.entries.map((row) => row.id)
|
|
44
|
+
},
|
|
45
|
+
get updatedAt() {
|
|
46
|
+
return cache.at
|
|
47
|
+
},
|
|
48
|
+
regionLabel: (region) => REGION_LABEL[region] ?? '未知站点',
|
|
49
|
+
|
|
50
|
+
async list({ force = false } = {}) {
|
|
51
|
+
if (!force && cache.entries.length > 0 && Date.now() - cache.at < ttlMs) return cache.entries
|
|
52
|
+
|
|
53
|
+
const merged = new Map()
|
|
54
|
+
for (const { region, base } of sites()) {
|
|
55
|
+
const ids = await fetchOne(base)
|
|
56
|
+
for (const raw of ids) {
|
|
57
|
+
const name = simplifyModelId(raw)
|
|
58
|
+
if (!name) continue
|
|
59
|
+
const slot = name.toLowerCase()
|
|
60
|
+
const row = merged.get(slot) ?? { name, variants: {}, rawIds: [] }
|
|
61
|
+
if (!row.rawIds.includes(raw)) row.rawIds.push(raw)
|
|
62
|
+
// 同一个站点里同名的,保留第一个(通常是主推的 org)。
|
|
63
|
+
if (!row.variants[region]) row.variants[region] = raw
|
|
64
|
+
merged.set(slot, row)
|
|
65
|
+
}
|
|
66
|
+
if (sites().length > 1) await new Promise((resolve) => setTimeout(resolve, 300))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (merged.size > 0) {
|
|
70
|
+
const entries = [...merged.values()]
|
|
71
|
+
.map((row) => ({
|
|
72
|
+
id: row.name,
|
|
73
|
+
name: row.name,
|
|
74
|
+
regions: preferred.filter((key) => Boolean(row.variants[key])),
|
|
75
|
+
variants: row.variants,
|
|
76
|
+
rawIds: row.rawIds,
|
|
77
|
+
}))
|
|
78
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
79
|
+
cache = { at: Date.now(), entries }
|
|
80
|
+
}
|
|
81
|
+
return cache.entries
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** 客户端给的名字(简化名 / 上游 id / 大小写不同)找到目录里的那一项。 */
|
|
85
|
+
resolve(text) {
|
|
86
|
+
const needle = String(text ?? '').trim().toLowerCase()
|
|
87
|
+
if (!needle) return undefined
|
|
88
|
+
return (
|
|
89
|
+
cache.entries.find((row) => row.id.toLowerCase() === needle) ??
|
|
90
|
+
cache.entries.find((row) => row.rawIds.some((raw) => raw.toLowerCase() === needle))
|
|
91
|
+
)
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** 这个站点该用哪个上游 id:有本站点变体就用它,没有就退回任意一个。 */
|
|
95
|
+
modelFor(text, region) {
|
|
96
|
+
const group = this.resolve(text)
|
|
97
|
+
if (!group) return ''
|
|
98
|
+
return group.variants?.[region] ?? group.variants?.[preferred[0]] ?? group.rawIds[0] ?? ''
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
regionOf(id) {
|
|
102
|
+
return this.resolve(id)?.regions ?? []
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|