@tzyc/dsh-model-visibility 1.9.0 → 1.10.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/README.md +50 -30
- package/client-src/catalog-store.mjs +464 -0
- package/client-src/checker-view.mjs +221 -0
- package/client-src/composer-filter.mjs +153 -0
- package/client-src/constants.mjs +20 -0
- package/client-src/css.mjs +248 -0
- package/client-src/dict.mjs +427 -0
- package/client-src/format.mjs +154 -0
- package/client-src/hooks.mjs +25 -0
- package/client-src/index.mjs +101 -0
- package/client-src/override-editor.mjs +286 -0
- package/client-src/pure.mjs +136 -0
- package/client-src/settings-section.mjs +581 -0
- package/client-src/tags.mjs +197 -0
- package/client-src/usage-view.mjs +112 -0
- package/client.js +3228 -2258
- package/index.js +178 -0
- package/lib/capability-catalog.js +5 -69
- package/lib/capability-corrections.js +1 -1
- package/lib/check-catalog.js +575 -0
- package/lib/export-csv.js +69 -0
- package/lib/learned-catalog.js +294 -0
- package/lib/model-id.js +50 -0
- package/lib/model-info.js +94 -52
- package/lib/model-overrides.js +1 -1
- package/lib/usage-ledger.js +574 -0
- package/package.json +65 -64
- package/lib/model-catalog.js +0 -670
package/README.md
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](package.json)
|
|
11
|
-
[](test)
|
|
12
|
+
[](https://github.com/TZYCeng/dsh-model-visibility/releases)
|
|
13
13
|
[](https://awesome-dsh-plugin.com/p/TZYCeng/dsh-model-visibility/)
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
17
|
-
> **In English:** a DeepSeek Harness bundle that adds a *Settings → Model visibility* page. Every model of every provider gets a switch (off models vanish from the composer's model menu), and every remaining model is labelled with what it can actually do — reasoning support and the reasoning-effort levels the adapter publishes, context window, input modalities, and whether it is a chat, image, video, embedding, rerank, audio, classifier or translation model. The list can be searched by name or ID, filtered by kind, by capability (multimodal, reasoning) and by minimum context window, and provider groups can be collapsed. Labels merge
|
|
17
|
+
> **In English:** a DeepSeek Harness bundle that adds a *Settings → Model visibility* page. Every model of every provider gets a switch (off models vanish from the composer's model menu), and every remaining model is labelled with what it can actually do — reasoning support and the reasoning-effort levels the adapter publishes, context window, input modalities, and whether it is a chat, image, video, embedding, rerank, audio, classifier or translation model. The list can be searched by name or ID, filtered by kind, by capability (multimodal, reasoning) and by minimum context window, and provider groups can be collapsed. Labels merge the sources in priority order (manual override, settings document, adapter, the relay's own `GET /models`, a learned local snapshot seeded online, vendor corrections), the page names the source on hover; the page names the source on hover, with solid borders for declared values and dashed for inferred ones. Any model can be edited by hand — kind, context window, modalities, effort levels — and those edits can be written back into the model provider's own settings. A switch, on by default, makes relay routes whose models declare text-only input accept images in memory, without editing any configuration file. A token-usage strip (today / week / month / total) with two one-click CSV exports lives at the top of the page, and an online verification panel re-checks every routable model's capabilities. The rest of this README is in Chinese.
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
20
|
<img src="docs/settings-page.png" width="49%" alt="设置 → 模型显示:分组开关、筛选工具栏、能力图例与图像放行开关">
|
|
@@ -57,12 +57,14 @@ yujianwudi:
|
|
|
57
57
|
| **分组批量操作** | 每组标题右侧「全组隐藏 / 全组显示」,工具栏「全部显示」一次清空隐藏;筛选状态下只作用于当前筛选结果 |
|
|
58
58
|
| **分组折叠** | 点分组标题左侧箭头折叠该组,只留标题与统计;工具栏「折叠全部分组 / 展开全部分组」一次切换;状态存在浏览器 `localStorage`,不改任何模型开关 |
|
|
59
59
|
| **能力标签** | `支持推理` / 不支持 / 未知 + 适配器公布的全部思考强度档位;`上下文 1M`;`多模态` / `纯文本` / 未知;类型色条与类型标签区分 `对话 / 生图 / 生视频 / 嵌入 / 重排 / 音频 / 审核 / 翻译` |
|
|
60
|
-
| **来源分层** | 实线 = 已声明(手动 / 设置文档 / 适配器 / 中转站),虚线 =
|
|
60
|
+
| **来源分层** | 实线 = 已声明(手动 / 设置文档 / 适配器 / 中转站),虚线 = 补全或推断(学习快照 / 模型名),悬停显示具体来源 |
|
|
61
61
|
| **筛选** | 名称 / ID 搜索;类型快捷筛选 `全部 / 对话 / 生图 / 生视频 / 其它`(带数量);能力筛选 `多模态`、`支持推理`;最小上下文 `128K / 256K / 512K / 1M`;多个条件同时生效 |
|
|
62
62
|
| **手动覆盖能力** | 每行的「编辑能力」可指定类型、上下文、输入模态、思考强度(含默认档 / 不支持推理),来源标为 `手动`;清空保存即恢复自动识别 |
|
|
63
63
|
| **写回提供商** | 编辑器里勾「同时写入模型提供商设置」,把 `input` / `contextWindow` / `reasoningEfforts` 写进 `llm-pi-ai` 的该模型条目,让输入框、适配器与其他设置界面读到同一份事实 |
|
|
64
64
|
| **官方校正** | 厂商文档能证明的错误行(如 `glm-5.3` 被目录标成多模态)显示 `官方校正` + 文档链接,并压过目录与中转站列表 |
|
|
65
65
|
| **图像放行** | 默认开启:判定可输入图像的中转站对话模型,在请求侧补上 `image`,不用改任何配置文件;关闭即回到适配器声明 |
|
|
66
|
+
| **token 用量** | 页面顶部常驻:今天 / 本周 / 本月 / 总计的输入与输出 token(缓存读写合计进输入);一键导出用量 CSV 与模型清单 CSV |
|
|
67
|
+
| **联网校验** | 选一个「校验员」模型:先拉 models.dev / OpenRouter 公开目录做基准,再分批核对其余模型的类型 / 上下文 / 模态 / 推理档位;结果写进本页与本地快照 `learned.json`(同名模型跨路由复用缓存),开关打开时同时写入提供商配置 |
|
|
66
68
|
| **重新获取能力** | 工具栏「重新获取能力」作废旧缓存与进行中的扫描,重新询问适配器与所有中转站(比「刷新」更硬,只绕过 TTL 的「刷新」仍会合并进行中的读取) |
|
|
67
69
|
| **ID 归一化** | `openai/gpt-4o`、`~deepseek-v3`、`glm-5.3:free`、`:free:batch` 这类前缀 / 叠加后缀先归一化再匹配目录,避免「同一个模型查不到」 |
|
|
68
70
|
| **持久化** | 隐藏列表写进 profile 的 `cordis.patch.yml`,并镜像到 `$DSH_HOME/dsh-model-visibility/hidden.json`,插件重装 / profile 自愈后自动恢复;手动覆盖单独一份 `overrides.json` |
|
|
@@ -107,9 +109,9 @@ npm install
|
|
|
107
109
|
| --- | --- | --- | --- |
|
|
108
110
|
| 0 | `manual` | 用户在设置页为该模型手动声明的类型 / 上下文 / 模态 / 思考强度(可同时写进 Provider 配置) | 实线 |
|
|
109
111
|
| 1 | `config` | 设置文档里该模型显式写了 `input`、`contextWindow`、`maxTokens`,或 Provider 的 `defaultInput` 显式包含非文本模态 | 实线 |
|
|
110
|
-
| 2 | `adapter` |
|
|
112
|
+
| 2 | `adapter` | 适配器不是从中转站配置(学习快照),或该模型不在 Provider 的 `models` 列表里 | 实线 |
|
|
111
113
|
| 3 | `provider` | 中转站的 `GET {baseURL}/models` 返回了 `architecture.input_modalities` / `input_modalities` / `capabilities` / `type` / `supported_endpoint_types` | 实线 |
|
|
112
|
-
| 4 | `
|
|
114
|
+
| 4 | `snapshot` | 学习快照 `learned.json` 里有同名模型(联网校验的结果,含公开目录与校验员来源) | 虚线 |
|
|
113
115
|
| 5 | `inferred` | 模型名带明确家族特征(`claude` / `gpt-4o+` / `gemini` / `grok-4+` / `vl` / `vision` / `image` / `video` / `embed` …) | 虚线 |
|
|
114
116
|
| 校正 | `official` | 厂商文档明确指出该行写错(`lib/capability-corrections.js`) | 实线 |
|
|
115
117
|
| — | `unknown` | 以上都没有:显示未知,**不会**把 `defaultInput: [text]` 当成纯文本 | 灰色 |
|
|
@@ -164,7 +166,7 @@ npm install
|
|
|
164
166
|
|
|
165
167
|
## 官方校正
|
|
166
168
|
|
|
167
|
-
`lib/capability-corrections.js` 是一张很小的、只收厂商文档能证明的修正表。首条是 `glm-5.3
|
|
169
|
+
`lib/capability-corrections.js` 是一张很小的、只收厂商文档能证明的修正表。首条是 `glm-5.3`:旧快照把它标成 `text+image`,但智谱文档写明 GLM-5.3 只处理文本,原生多模态的是 GLM-5.3-Flash / FlashX。校正只锁精确 id,`glm-5.3-flash` 仍然是多模态。
|
|
168
170
|
|
|
169
171
|
被校正的行为显示 `官方校正` 标签,悬停有说明与文档链接;它同时作用于页面和图像闸门,所以 `glm-5.3` 不会被放行图片。
|
|
170
172
|
|
|
@@ -189,20 +191,21 @@ Host 端通过 `settings.describe()` 读取配置了 `baseURL` 与 `apiKeyEnv`
|
|
|
189
191
|
|
|
190
192
|
安全约束:不跟随重定向(避免把 `Authorization` 带到别的域)、4 MB 响应上限、每请求 3.5 s 超时、Provider 并发探测、结果缓存 10 分钟。关闭探测:设置行写 `probe: false`,或环境变量 `MODEL_VISIBILITY_PROBE=0`。
|
|
191
193
|
|
|
192
|
-
##
|
|
194
|
+
## 学习快照(联网校验)
|
|
193
195
|
|
|
194
|
-
|
|
196
|
+
静态内置目录(models.dev 2026-10-07 快照)已删除:多数中转站不公布上下文 / 模态 / 推理档位,静态表会过时。替代品是**学习快照** `~/.dsh/dsh-model-visibility/learned.json`,数据来自两层:
|
|
195
197
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
node .tools/asar.mjs extract "<DeepSeek Harness>/resources/app.asar" `
|
|
199
|
-
"dsh/node_modules/@earendil-works/pi-ai" ".tools/extract/pi-ai"
|
|
198
|
+
1. **公开目录基准**:`https://models.dev/api.json` 与 `https://openrouter.ai/api/v1/models`(无需密钥),按归一化 id 匹配;
|
|
199
|
+
2. **校验员模型**:在插件设置页选定一个提供商的模型作为「校验员」,其余模型按批(默认 6 个/批、并发 2)经 `llm.prepareCall` 发给它,要求输出结构化 JSON 事实;`unknown` 一律允许,不猜。
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
结果落地:
|
|
202
|
+
|
|
203
|
+
- 页面立即按新事实显示,来源标 `已校验`(虚线);写回提供商配置后相关字段提升为 `config` 实线;
|
|
204
|
+
- 「校验通过即写入提供商配置」(默认开):可映射字段(`input` / `contextWindow` / `reasoningEfforts`)经 `settings.mutate` 写进每个服务该模型的提供商条目,与手动覆盖共用同一条写回路径;
|
|
205
|
+
- 全部事实存进 `learned.json`,键为归一化模型 id(去聚合前缀 / `:free` 后缀):同名模型在任何路由直接命中缓存;
|
|
206
|
+
- 校验自身消耗的 token 记入用量账本。
|
|
204
207
|
|
|
205
|
-
|
|
208
|
+
进度、取消与最终统计由 `GET/POST /api/dsh-model-visibility/verify` 提供,面板内轮询展示。
|
|
206
209
|
|
|
207
210
|
## 路由
|
|
208
211
|
|
|
@@ -210,6 +213,10 @@ node scripts/build-catalog.mjs --data .tools/extract/pi-ai
|
|
|
210
213
|
GET /api/dsh-model-visibility/models[?fresh=1][&reload=1]
|
|
211
214
|
GET /api/dsh-model-visibility/overrides
|
|
212
215
|
POST /api/dsh-model-visibility/overrides
|
|
216
|
+
GET /api/dsh-model-visibility/usage
|
|
217
|
+
GET /api/dsh-model-visibility/export?type=usage|models
|
|
218
|
+
GET /api/dsh-model-visibility/verify
|
|
219
|
+
POST /api/dsh-model-visibility/verify
|
|
213
220
|
```
|
|
214
221
|
|
|
215
222
|
Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()`,叠加设置文档与中转站探测结果,返回:
|
|
@@ -234,7 +241,7 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
234
241
|
}],
|
|
235
242
|
"failures": [],
|
|
236
243
|
"providers": [{ "id": "yujianwudi", "status": "ok", "models": 58, "capabilities": 14 }],
|
|
237
|
-
"
|
|
244
|
+
"learned": { "models": 42, "updatedAt": 1762000000000, "checker": { "provider": "relay", "model": "gpt-6" } }
|
|
238
245
|
}
|
|
239
246
|
}
|
|
240
247
|
```
|
|
@@ -253,13 +260,16 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
253
260
|
| `lib/model-info.js` | 能力合并:证据优先级、`describeCatalog`、15 s 缓存、图像准入判定 |
|
|
254
261
|
| `lib/model-overrides.js` | 手动覆盖的读写与校验(`overrides.json`,按 mtime 重读) |
|
|
255
262
|
| `lib/provider-write.js` | 把可对应字段写回 `llm-pi-ai` Provider 配置(`settings.mutate` 路径操作) |
|
|
263
|
+
| `lib/learned-catalog.js` | 学习快照 `learned.json` 的读写与归一化 id 匹配 |
|
|
264
|
+
| `lib/check-catalog.js` | 联网校验:公开目录双源解析、校验员分批、写回与进度状态 |
|
|
265
|
+
| `lib/usage-ledger.js` | token 用量账本:会话日志 zstd 帧解码、按天 / 按模型折叠与增量扫描 |
|
|
266
|
+
| `lib/export-csv.js` | 一键导出的两个 CSV 生成器(用量 / 模型清单) |
|
|
256
267
|
| `lib/provider-probe.js` | 读取 Provider 配置、解析凭据、探测中转站、10 min 缓存与诊断 |
|
|
257
|
-
| `lib/capability-catalog.js` |
|
|
268
|
+
| `lib/capability-catalog.js` | 模型类型与家族模态启发式(快照层之后的最后兜底) |
|
|
258
269
|
| `lib/capability-corrections.js` | 厂商文档校正表(如 GLM-5.3 纯文本),带来源链接 |
|
|
259
270
|
| `lib/image-input-gate.js` | 在 pi-ai 适配器上补图像输入能力(可逆) |
|
|
260
|
-
| `
|
|
261
|
-
| `
|
|
262
|
-
| `client.js` | 浏览器端:设置页、筛选、折叠、覆盖编辑器、能力标签、就地包装模型目录 store |
|
|
271
|
+
| `client-src/*.mjs` | 浏览器端源码(纯函数模块可被 `node --test` 直测),`scripts/build-client.mjs` 机械拼装成单文件 bundle |
|
|
272
|
+
| `client.js` | 构建产物(勿手改):设置页、筛选、折叠、覆盖编辑器、能力标签、用量条、校验面板、模型目录 store 过滤 |
|
|
263
273
|
| `locale/*.json` | 插件列表里的标题与描述 |
|
|
264
274
|
| `test/*.test.mjs` | 72 个单元 / 集成测试 |
|
|
265
275
|
| `icon.svg` · `docs/` | 插件图标与截图 |
|
|
@@ -282,9 +292,9 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
282
292
|
- `describeResolvedModel` 只把 `resolveModelInfo()` 的结果变成纯对象;真正的合并发生在 `mergeCapabilities`(纯函数,单测直接覆盖)。
|
|
283
293
|
- `profile.models` 里为空数组的 `input` 视为「未声明」,不是「仅文本」;只有显式非空 `input`,或 `defaultInput` 明确包含非文本模态时,才记为 `config`。
|
|
284
294
|
- 上下文同理:中转站的 `defaultContextWindow: 262144` 是 DSH 默认值,不当事实使用。
|
|
285
|
-
- 模型类型优先取中转站的 `type` / `supported_endpoint_types
|
|
295
|
+
- 模型类型优先取中转站的 `type` / `supported_endpoint_types`,其次学习快照 `type`,最后用模型名关键词;生成类模型走 `generationTags`,不显示推理档位与文本标签。
|
|
286
296
|
- 所有字段可选:不知道就显示未知,不猜。
|
|
287
|
-
- 厂商校正只在 `
|
|
297
|
+
- 厂商校正只在 `snapshot` / `inferred` / `unknown` / `provider` 上生效;被校正的模型带 `correction` 记录,页面渲染 `官方校正`。
|
|
288
298
|
|
|
289
299
|
</details>
|
|
290
300
|
|
|
@@ -307,6 +317,14 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
307
317
|
|
|
308
318
|
</details>
|
|
309
319
|
|
|
320
|
+
## token 用量账本
|
|
321
|
+
|
|
322
|
+
- 数据源:`$DSH_HOME/sessions/**` 的会话日志(逐行 zstd 帧;`assistant/message` / `assistant/attempt` 携带 provider 回报的 `usage` 与路由归属)。
|
|
323
|
+
- 折叠语义与官方 token-meter 的 `tokenUsage` 投影逐位一致(同 (turn, step) 的修订替换、`llm/retry-started` 让重试加计;已在参考日志上对账验证)。
|
|
324
|
+
- 扫描增量:文件 (mtime, size) 未变即复用 `usage-ledger.json` 缓存;首次全量在后台(几百 MB 约几十秒),之后近 0 成本。
|
|
325
|
+
- 当前月的记录按事件日精确到天;更早的按会话创建日粗算(悬停有说明),模型归属始终精确。
|
|
326
|
+
- 校验功能的消耗按同口径入账。
|
|
327
|
+
|
|
310
328
|
## 持久化
|
|
311
329
|
|
|
312
330
|
`cordis.patch.yml` 是「用户覆盖层」,DSH 会整层重建它:
|
|
@@ -330,30 +348,32 @@ Host 遍历 `ctx.llm.listProviders()` → `listModels()` → `resolveModelInfo()
|
|
|
330
348
|
npm test
|
|
331
349
|
```
|
|
332
350
|
|
|
333
|
-
|
|
351
|
+
112 个用例。覆盖:证据优先级、默认文本不被当成事实、三种中转站 dialect 的解析、凭据缺失与 401、`/v1/models` 回退、缓存 TTL / `fresh` / `reload`、模型类型与家族模态、目录别名匹配、推理档位清洗、官方校正、手动覆盖的校验与持久化(含损坏文件与外部改动)、Provider 回写(档位映射、按模型下标写入、各种拒绝原因、写后字段剥离)、请求侧图像准入与适配器闸门的可逆性,以及 Host 接线(能力路由 + 校正 + `applyInput` 开关 + 硬重载提升 `reload` 能力位 + 覆盖读写路由 + 回写后网关立即按新配置放行)。
|
|
334
352
|
|
|
335
353
|
GUI 冒烟(改完代码后):
|
|
336
354
|
|
|
337
|
-
1. 改 `client
|
|
355
|
+
1. 改 `client-src/*.mjs` → `npm run build` → `Ctrl+R`,确认标签、筛选、虚线来源、类型颜色与左侧色条。
|
|
338
356
|
2. 折叠分组,刷新后仍在;工具栏一次切换全部分组,不影响开关。
|
|
339
357
|
3. 改 `index.js` / `lib` → 重启 DSH(或等 profile 重组)。
|
|
340
|
-
4.
|
|
358
|
+
4. 重载后确认页面底部有「中转站探测:…」与「学习快照:…」,Grok / Claude / GPT / Gemini 显示真实上下文与多模态;`glm-5.3` 显示 `纯文本` + `官方校正`。
|
|
341
359
|
5. 「重新获取能力」可用,点一次后列表重新加载。
|
|
342
360
|
6. 打开输入框模型菜单:被关的模型不出现,当前模型仍显示。
|
|
343
361
|
7. 给标注 `多模态` 的中转站模型发图:直接成功;关掉开关后恢复「模型不支持图像输入」。
|
|
344
362
|
8. 点「编辑能力」保存后标签变 `手动`,刷新仍在;清空保存恢复自动。
|
|
345
363
|
9. 勾「同时写入模型提供商设置」,确认提示已写入,Provider 设置里能看到 `input` / `contextWindow` / `reasoningEfforts`。
|
|
364
|
+
10. 顶部用量条显示今天 / 本周 / 本月 / 总计(首次扫描期间显示「统计中…」);两个导出按钮各得一份 CSV。
|
|
365
|
+
11. 展开校验面板,选定校验员并开始:进度逐批推进,结束后相关能力标签变为「已校验」来源;关掉写回开关时 Provider 配置保持不变。
|
|
346
366
|
|
|
347
367
|
## 已知限制
|
|
348
368
|
|
|
349
|
-
-
|
|
350
|
-
-
|
|
369
|
+
- **学习快照要喂养**:没跑过校验的新模型靠探测或模型名推断;公开目录覆盖不到冷门路由模型时可手改 `learned.json` 或再校验一次。
|
|
370
|
+
- **官方校正是手工维护的小表**:只收厂商文档能证明的错误行;校验员答错时它是最后防线。
|
|
351
371
|
- **中转站不一定公布能力**:多数 `/v1/models` 只有 id;New API 的 `/api/pricing` 经常被关闭(403)。
|
|
352
372
|
- **模型名推断保守**:不在目录、名字也没特征的自定义模型显示未知,不猜。
|
|
353
373
|
- **DSH 的模态契约只有 text / image**:视频 / 音频输入无法进入官方请求准入;请求侧对齐也只处理 `image`。
|
|
354
374
|
- **请求侧对齐只覆盖 pi-ai 中转站**:内置适配器与其它协议按自己的声明工作。
|
|
355
375
|
- **适配器声明优先**:Provider 明确写了 `input: [text]` 时页面尊重该声明,想改就以 Provider 配置为准。
|
|
356
|
-
- **「重新获取能力」造不出适配器没公布的档位**:pi-ai 适配器会缓存自己的模型快照;配置没写 `reasoningEfforts`
|
|
376
|
+
- **「重新获取能力」造不出适配器没公布的档位**:pi-ai 适配器会缓存自己的模型快照;配置没写 `reasoningEfforts` 且学习快照没有档位时仍为空,可先「联网校验」再写回,或改配置后重启。
|
|
357
377
|
- 上下文按十进制缩写(262144 → `262.1K`),精确值在悬停里。
|
|
358
378
|
- 目录是实时注册表快照,动态免费模型源刷新时总数可能与输入框旧缓存略有出入,点「刷新」对齐。
|
|
359
379
|
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { API } from './constants.mjs'
|
|
2
|
+
|
|
3
|
+
export function createCatalog(ctx) {
|
|
4
|
+
const listeners = new Set()
|
|
5
|
+
let state = {
|
|
6
|
+
status: 'idle',
|
|
7
|
+
groups: [],
|
|
8
|
+
failures: [],
|
|
9
|
+
providers: [],
|
|
10
|
+
catalog: null,
|
|
11
|
+
error: null,
|
|
12
|
+
detailsError: null,
|
|
13
|
+
source: null,
|
|
14
|
+
generatedAt: 0,
|
|
15
|
+
}
|
|
16
|
+
let inflight = null
|
|
17
|
+
// A hard reload starts a new run even while `inflight` is set; the token
|
|
18
|
+
// keeps the older run from publishing its now-stale result afterwards.
|
|
19
|
+
let generation = 0
|
|
20
|
+
|
|
21
|
+
const publish = (next) => {
|
|
22
|
+
state = next
|
|
23
|
+
for (const listener of listeners) listener()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Message of a failed Remote envelope. */
|
|
27
|
+
const failureMessage = (response) => {
|
|
28
|
+
const failure = response && response.error
|
|
29
|
+
return failure
|
|
30
|
+
? String(failure.code) + ': ' + String(failure.message)
|
|
31
|
+
: 'unknown error'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the enriched catalog from the Host half. It carries the same
|
|
36
|
+
* groups as `session.modelCatalog` plus context windows and modalities.
|
|
37
|
+
*/
|
|
38
|
+
const fetchHostCatalog = async (fresh, reload) => {
|
|
39
|
+
const query = reload ? '?fresh=1&reload=1' : fresh ? '?fresh=1' : ''
|
|
40
|
+
const response = await fetch(API + '/models' + query, {
|
|
41
|
+
method: 'GET',
|
|
42
|
+
headers: { accept: 'application/json' },
|
|
43
|
+
credentials: 'same-origin',
|
|
44
|
+
cache: 'no-store',
|
|
45
|
+
redirect: 'error',
|
|
46
|
+
})
|
|
47
|
+
if (!response.ok) throw new Error('HTTP ' + response.status)
|
|
48
|
+
const body = await response.json()
|
|
49
|
+
const value = body && body.ok === true ? body.value : null
|
|
50
|
+
if (value === null || !Array.isArray(value.groups)) {
|
|
51
|
+
throw new Error('malformed capability catalog')
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read the shipped catalog; the fallback when the route is unavailable. */
|
|
57
|
+
const fetchOfficialCatalog = async () => {
|
|
58
|
+
const response = await ctx.remote.session.modelCatalog()
|
|
59
|
+
if (!response || response.ok !== true) throw new Error(failureMessage(response))
|
|
60
|
+
const value = response.value || {}
|
|
61
|
+
return {
|
|
62
|
+
groups: Array.isArray(value.groups) ? value.groups : [],
|
|
63
|
+
failures: Array.isArray(value.failures) ? value.failures : [],
|
|
64
|
+
generatedAt: 0,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const load = (options) => {
|
|
69
|
+
const fresh = options !== null && typeof options === 'object' && options.fresh === true
|
|
70
|
+
const reload = options !== null && typeof options === 'object' && options.reload === true
|
|
71
|
+
// A soft load joins whatever is already running. A hard reload has to
|
|
72
|
+
// start its own run, otherwise it would receive the very stale result
|
|
73
|
+
// it is meant to replace.
|
|
74
|
+
if (inflight !== null && !reload) return inflight
|
|
75
|
+
if (state.status !== 'ready') {
|
|
76
|
+
publish({ ...state, status: 'loading', error: null })
|
|
77
|
+
}
|
|
78
|
+
const token = ++generation
|
|
79
|
+
const run = (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const value = await fetchHostCatalog(fresh, reload)
|
|
82
|
+
if (token !== generation) return
|
|
83
|
+
publish({
|
|
84
|
+
status: 'ready',
|
|
85
|
+
groups: Array.isArray(value.groups) ? value.groups : [],
|
|
86
|
+
failures: Array.isArray(value.failures) ? value.failures : [],
|
|
87
|
+
providers: Array.isArray(value.providers) ? value.providers : [],
|
|
88
|
+
catalog: value.catalog && typeof value.catalog === 'object' ? value.catalog : null,
|
|
89
|
+
error: null,
|
|
90
|
+
detailsError: null,
|
|
91
|
+
source: 'host',
|
|
92
|
+
generatedAt: Number.isFinite(value.generatedAt) ? value.generatedAt : 0,
|
|
93
|
+
})
|
|
94
|
+
return
|
|
95
|
+
} catch (detailError) {
|
|
96
|
+
const detail = String(detailError && detailError.message ? detailError.message : detailError)
|
|
97
|
+
try {
|
|
98
|
+
const value = await fetchOfficialCatalog()
|
|
99
|
+
if (token !== generation) return
|
|
100
|
+
publish({
|
|
101
|
+
status: 'ready',
|
|
102
|
+
groups: value.groups,
|
|
103
|
+
failures: value.failures,
|
|
104
|
+
providers: [],
|
|
105
|
+
catalog: null,
|
|
106
|
+
error: null,
|
|
107
|
+
detailsError: detail,
|
|
108
|
+
source: 'catalog',
|
|
109
|
+
generatedAt: value.generatedAt,
|
|
110
|
+
})
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (token !== generation) return
|
|
113
|
+
publish({
|
|
114
|
+
...state,
|
|
115
|
+
status: 'error',
|
|
116
|
+
error: String(error && error.message ? error.message : error),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
if (inflight === run) inflight = null
|
|
121
|
+
}
|
|
122
|
+
})()
|
|
123
|
+
inflight = run
|
|
124
|
+
return run
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
subscribe(fn) {
|
|
129
|
+
listeners.add(fn)
|
|
130
|
+
return () => {
|
|
131
|
+
listeners.delete(fn)
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
getSnapshot() {
|
|
135
|
+
return state
|
|
136
|
+
},
|
|
137
|
+
load,
|
|
138
|
+
refresh() {
|
|
139
|
+
return load({ fresh: true })
|
|
140
|
+
},
|
|
141
|
+
reload() {
|
|
142
|
+
return load({ fresh: true, reload: true })
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Manual capability overrides, persisted by the Host half.
|
|
149
|
+
*
|
|
150
|
+
* The page only edits one model at a time; the store keeps the whole list so
|
|
151
|
+
* every row knows whether it already has an override. An older Host half
|
|
152
|
+
* answers 404 and the editor stays hidden behind the `overrides` feature
|
|
153
|
+
* flag.
|
|
154
|
+
*/
|
|
155
|
+
export function createOverrides() {
|
|
156
|
+
const listeners = new Set()
|
|
157
|
+
let state = { status: 'idle', entries: [], available: null, error: null }
|
|
158
|
+
let inflight = null
|
|
159
|
+
|
|
160
|
+
const publish = (next) => {
|
|
161
|
+
state = next
|
|
162
|
+
for (const listener of listeners) listener()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Error text for a failed envelope or HTTP response. */
|
|
166
|
+
const failureOf = async (response) => {
|
|
167
|
+
let detail = ''
|
|
168
|
+
try {
|
|
169
|
+
const body = await response.json()
|
|
170
|
+
if (body && body.error) {
|
|
171
|
+
detail = ' (' + String(body.error.code) + ': ' + String(body.error.message) + ')'
|
|
172
|
+
}
|
|
173
|
+
} catch (_error) {
|
|
174
|
+
// A proxy error page is not JSON; the status code is still useful.
|
|
175
|
+
}
|
|
176
|
+
return new Error('HTTP ' + response.status + detail)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const readEntries = async (response) => {
|
|
180
|
+
if (response.status === 404) {
|
|
181
|
+
publish({ status: 'ready', entries: [], available: false, error: null })
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
if (!response.ok) throw await failureOf(response)
|
|
185
|
+
const body = await response.json()
|
|
186
|
+
const value = body && body.ok === true ? body.value : null
|
|
187
|
+
if (value === null || !Array.isArray(value.overrides)) {
|
|
188
|
+
throw new Error('malformed override list')
|
|
189
|
+
}
|
|
190
|
+
return value.overrides
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const load = () => {
|
|
194
|
+
if (inflight !== null) return inflight
|
|
195
|
+
if (state.status !== 'ready') publish({ ...state, status: 'loading', error: null })
|
|
196
|
+
const run = (async () => {
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(API + '/overrides', {
|
|
199
|
+
method: 'GET',
|
|
200
|
+
headers: { accept: 'application/json' },
|
|
201
|
+
credentials: 'same-origin',
|
|
202
|
+
cache: 'no-store',
|
|
203
|
+
redirect: 'error',
|
|
204
|
+
})
|
|
205
|
+
const entries = await readEntries(response)
|
|
206
|
+
if (entries !== null) {
|
|
207
|
+
publish({ status: 'ready', entries, available: true, error: null })
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
publish({
|
|
211
|
+
...state,
|
|
212
|
+
status: 'error',
|
|
213
|
+
error: String(error && error.message ? error.message : error),
|
|
214
|
+
})
|
|
215
|
+
} finally {
|
|
216
|
+
if (inflight === run) inflight = null
|
|
217
|
+
}
|
|
218
|
+
})()
|
|
219
|
+
inflight = run
|
|
220
|
+
return run
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** POST one entry (or `{remove:true}`) and adopt the returned list. */
|
|
224
|
+
const write = async (payload) => {
|
|
225
|
+
const response = await fetch(API + '/overrides', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
228
|
+
credentials: 'same-origin',
|
|
229
|
+
cache: 'no-store',
|
|
230
|
+
redirect: 'error',
|
|
231
|
+
body: JSON.stringify(payload),
|
|
232
|
+
})
|
|
233
|
+
if (response.status === 404) {
|
|
234
|
+
publish({ status: 'ready', entries: [], available: false, error: null })
|
|
235
|
+
throw new Error('HTTP 404')
|
|
236
|
+
}
|
|
237
|
+
if (!response.ok) throw await failureOf(response)
|
|
238
|
+
const body = await response.json()
|
|
239
|
+
const value = body && body.ok === true ? body.value : null
|
|
240
|
+
if (value === null || !Array.isArray(value.overrides)) {
|
|
241
|
+
throw new Error('malformed override list')
|
|
242
|
+
}
|
|
243
|
+
publish({ status: 'ready', entries: value.overrides, available: true, error: null })
|
|
244
|
+
return value
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
subscribe(fn) {
|
|
249
|
+
listeners.add(fn)
|
|
250
|
+
return () => {
|
|
251
|
+
listeners.delete(fn)
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
getSnapshot() {
|
|
255
|
+
return state
|
|
256
|
+
},
|
|
257
|
+
load,
|
|
258
|
+
save(entry) {
|
|
259
|
+
return write(entry)
|
|
260
|
+
},
|
|
261
|
+
remove(provider, model) {
|
|
262
|
+
return write({ remove: true, provider, model })
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ------------------------------------------------------ composer filter */
|
|
268
|
+
|
|
269
|
+
/** Common failure text for envelopes and HTTP errors. */
|
|
270
|
+
function failureText(error) {
|
|
271
|
+
return String(error && error.message ? error.message : error)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Token-usage store behind the page's stats strip.
|
|
276
|
+
*
|
|
277
|
+
* Reads are cheap; the Host keeps a ledger and rescans on its own schedule,
|
|
278
|
+
* so the client only polls and prints. An older Host answers 404 and the
|
|
279
|
+
* strip renders its "host is stale" hint.
|
|
280
|
+
*/
|
|
281
|
+
export function createUsageApi() {
|
|
282
|
+
const listeners = new Set()
|
|
283
|
+
let state = { status: 'idle', snapshot: null, error: null }
|
|
284
|
+
let inflight = null
|
|
285
|
+
let timer = null
|
|
286
|
+
|
|
287
|
+
const publish = (next) => {
|
|
288
|
+
state = next
|
|
289
|
+
for (const listener of listeners) listener()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const read = async () => {
|
|
293
|
+
if (inflight !== null) return inflight
|
|
294
|
+
const run = (async () => {
|
|
295
|
+
try {
|
|
296
|
+
const response = await fetch(API + '/usage', {
|
|
297
|
+
headers: { accept: 'application/json' },
|
|
298
|
+
credentials: 'same-origin',
|
|
299
|
+
cache: 'no-store',
|
|
300
|
+
redirect: 'error',
|
|
301
|
+
})
|
|
302
|
+
if (!response.ok) throw new Error('HTTP ' + response.status)
|
|
303
|
+
const body = await response.json()
|
|
304
|
+
const value = body && body.ok === true ? body.value : null
|
|
305
|
+
if (value === null || typeof value !== 'object') throw new Error('malformed usage snapshot')
|
|
306
|
+
publish({ status: 'ready', snapshot: value, error: null })
|
|
307
|
+
} catch (error) {
|
|
308
|
+
publish({ ...state, status: 'error', error: failureText(error) })
|
|
309
|
+
} finally {
|
|
310
|
+
if (inflight === run) inflight = null
|
|
311
|
+
}
|
|
312
|
+
})()
|
|
313
|
+
inflight = run
|
|
314
|
+
return run
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One poll now and another every POLL_MS while someone subscribes. */
|
|
318
|
+
const POLL_MS = 30_000
|
|
319
|
+
const startTimer = () => {
|
|
320
|
+
if (timer !== null) return
|
|
321
|
+
timer = setInterval(() => read(), POLL_MS)
|
|
322
|
+
}
|
|
323
|
+
const stopTimer = () => {
|
|
324
|
+
if (timer !== null) {
|
|
325
|
+
clearInterval(timer)
|
|
326
|
+
timer = null
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
subscribe(fn) {
|
|
332
|
+
const first = listeners.size === 0
|
|
333
|
+
listeners.add(fn)
|
|
334
|
+
if (first) startTimer()
|
|
335
|
+
return () => {
|
|
336
|
+
listeners.delete(fn)
|
|
337
|
+
if (listeners.size === 0) stopTimer()
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
getSnapshot() {
|
|
341
|
+
return state
|
|
342
|
+
},
|
|
343
|
+
load() {
|
|
344
|
+
return read()
|
|
345
|
+
},
|
|
346
|
+
refresh() {
|
|
347
|
+
// A POST rescan is never needed: the Host rescan throttle refreshes on
|
|
348
|
+
// reads anyway. A plain read is enough here.
|
|
349
|
+
return read()
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The verify sweep's client store: read progress, start, cancel. The Host
|
|
356
|
+
* keeps the run in memory, so the store just mirrors its snapshots and keeps
|
|
357
|
+
* one 2 s poll alive while a sweep is running (or being observed).
|
|
358
|
+
*/
|
|
359
|
+
export function createVerifyApi() {
|
|
360
|
+
const listeners = new Set()
|
|
361
|
+
let state = { status: 'idle', snapshot: null, error: null }
|
|
362
|
+
let inflight = null
|
|
363
|
+
let timer = null
|
|
364
|
+
|
|
365
|
+
const publish = (next) => {
|
|
366
|
+
state = next
|
|
367
|
+
for (const listener of listeners) listener()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const read = async () => {
|
|
371
|
+
if (inflight !== null) return inflight
|
|
372
|
+
const run = (async () => {
|
|
373
|
+
try {
|
|
374
|
+
const response = await fetch(API + '/verify', {
|
|
375
|
+
headers: { accept: 'application/json' },
|
|
376
|
+
credentials: 'same-origin',
|
|
377
|
+
cache: 'no-store',
|
|
378
|
+
redirect: 'error',
|
|
379
|
+
})
|
|
380
|
+
if (!response.ok) throw new Error('HTTP ' + response.status)
|
|
381
|
+
const body = await response.json()
|
|
382
|
+
const value = body && body.ok === true ? body.value : null
|
|
383
|
+
if (value === null || typeof value !== 'object') throw new Error('malformed verify snapshot')
|
|
384
|
+
publish({ status: 'ready', snapshot: value, error: null })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
publish({ ...state, status: 'error', error: failureText(error) })
|
|
387
|
+
} finally {
|
|
388
|
+
if (inflight === run) inflight = null
|
|
389
|
+
}
|
|
390
|
+
})()
|
|
391
|
+
inflight = run
|
|
392
|
+
return run
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const post = async (payload) => {
|
|
396
|
+
const response = await fetch(API + '/verify', {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
399
|
+
credentials: 'same-origin',
|
|
400
|
+
cache: 'no-store',
|
|
401
|
+
redirect: 'error',
|
|
402
|
+
body: JSON.stringify(payload ?? {}),
|
|
403
|
+
})
|
|
404
|
+
if (response.status === 404) throw new Error('HTTP 404')
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
let detail = ''
|
|
407
|
+
try {
|
|
408
|
+
const body = await response.json()
|
|
409
|
+
if (body?.error) detail = String(body.error.code) + ': ' + String(body.error.message)
|
|
410
|
+
} catch (_error) {
|
|
411
|
+
// Non-JSON error body: the status code is enough.
|
|
412
|
+
}
|
|
413
|
+
throw new Error('HTTP ' + response.status + detail)
|
|
414
|
+
}
|
|
415
|
+
const body = await response.json()
|
|
416
|
+
if (body?.ok === true) {
|
|
417
|
+
publish({ status: 'ready', snapshot: body.value, error: null })
|
|
418
|
+
return body.value
|
|
419
|
+
}
|
|
420
|
+
throw new Error(failureText(body?.error))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const SWEEP_POLL_MS = 2_000
|
|
424
|
+
const IDLE_POLL_MS = 60_000
|
|
425
|
+
const startTimer = () => {
|
|
426
|
+
if (timer !== null) return
|
|
427
|
+
timer = setInterval(() => {
|
|
428
|
+
void read()
|
|
429
|
+
}, state.snapshot?.state?.running === true ? SWEEP_POLL_MS : IDLE_POLL_MS)
|
|
430
|
+
}
|
|
431
|
+
const stopTimer = () => {
|
|
432
|
+
if (timer !== null) {
|
|
433
|
+
clearInterval(timer)
|
|
434
|
+
timer = null
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
subscribe(fn) {
|
|
440
|
+
const first = listeners.size === 0
|
|
441
|
+
listeners.add(fn)
|
|
442
|
+
if (first) {
|
|
443
|
+
void read()
|
|
444
|
+
startTimer()
|
|
445
|
+
}
|
|
446
|
+
return () => {
|
|
447
|
+
listeners.delete(fn)
|
|
448
|
+
if (listeners.size === 0) stopTimer()
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
getSnapshot() {
|
|
452
|
+
return state
|
|
453
|
+
},
|
|
454
|
+
load() {
|
|
455
|
+
return read()
|
|
456
|
+
},
|
|
457
|
+
start(payload) {
|
|
458
|
+
return post(payload)
|
|
459
|
+
},
|
|
460
|
+
cancel() {
|
|
461
|
+
return post({ cancel: true })
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
}
|