Bundle
dsh-plugin-modelwatch
DSH OpenRouter 模型监控插件:新上模型 / 热门周榜 / 变化流水。全程只读 GET,不推外部通知。
- Source
- gh-gongjin
- License
- MIT
- Updated
- Updated 4 days ago
Readme
# dsh-plugin-modelwatch
DSH(deepseek HARNESS)的 **OpenRouter 模型监控**插件:在侧边栏「插件」下方增加一个入口,盯两件事 —— **哪些模型新上了**、**本周热门榜怎么变**。变化只记录在本插件面板里,不推任何外部通知。
全程**只读 GET**(每轮三次,点开模型详情时再按需一次):不带 key、不 POST 到 openrouter、不推外部通知。
**它是什么**
- **新上模型**:由清单的 `created` 时间戳直接算近 N 天(首轮建档就有东西可看,不依赖 diff),表格给出 name + slug / 上架时间 / 上下文 / 输入价 / 输出价
- **热门周榜**:按 token 用量的周榜,取前 `topN`(默认 15,可设 5~20),名次用金/银/铜徽标,量级条按榜首归一 —— 纯文本列看不出第 1 名与第 15 名差 20 倍
- **免费榜单**:免费变体(`:free`)模型的周榜,独立源、独立闸门,默认同样 15 条(真页可排 27 个免费模型)
- **变化流水**:追加式时间线,事件种类为 `new_model / removed_model / top_enter / top_exit / top_move / source_error`(中文标签定义在宿主侧 `lib/domain.js`,界面只查表);位次挪动 **≥3** 才记 `top_move`,周榜按天滚动更新,日间小幅挪动视为噪声不记
- **模型详情**:页面里六处模型名都可点,点开是居中弹框,列该模型的上架时间、上下文、价格与**逐条端点**(按需向官方清单 API 的 `/models/<slug>/endpoints` 现取,10 分钟内同模型不重复取;不进定时链路、不落 KV)
- **设置**:监测频率(1 / 6 / 12 / 24 小时)、榜单条数、事件保留条数(默认 500,范围 50~2000),外加一张只读的「运行环境」能力表
**它不是什么**
- 不是 OpenRouter 官方工具,与 openrouter.ai 无隶属关系;榜单数字是第三方口径的用量估算。
- 不做自动切换模型、不做调用、不做成本告警、不推通知。要通知请用宿主自己的机制,本插件不代作决定。
---
## 一个必须先说清的口径
热门周榜的来源是 `GET https://openrouter.ai/rankings` 的 **HTML 页面内嵌 react-query 水合数据**,属于**非官方接口**:解析路径是把 flight 字符串逐段反转义 → 取 `{"dehydratedAt"…}` 对象 → 找 `queryKey` 含 `"rankings","models"` 的那条 → `state.data`。**对方改版即失效。**
免费榜单走**另一个非官方读接口**:`GET https://openrouter.ai/api/frontend/v1/rankings/models?view=week`(榜单页自己的前端数据接口,返回每模型一行的周汇总)。为什么不从上面那 20 行里筛 —— 真页周榜里只有 1 行是 `:free` 变体,成不了榜;换源后有 **27 个免费模型**可排。名次口径 = `rankingMetricValue`(= prompt+completion 周总量,与 SSR 页逐值核对过);**端点行序不是站点名次序**,所以免费榜按 token 量自己排,这一点在卡片的来源注里如实写明。热门周榜仍然只读 SSR 那 20 行 —— 那才是站点页面上显示的名次。
因此:
| 情况 | 面板表现 |
|---|---|
| 清单源(官方 API)失败 | 状态卡就地写「清单源不可用:<原因>」,保留上次快照 |
| 榜单源解析失败 | 榜单卡就地写「榜单源结构变化,解析失败」+ 保留上次榜单;卡标题旁**常驻**小字「来源:榜单页内嵌数据,非官方接口」 |
| 免费榜源失败 | 免费榜卡就地写「本轮榜单源故障:<原因>;下面显示的是上次成功数据」—— **不连坐**周榜卡,反之亦然(三源三道独立闸门) |
| 详情源失败 | 弹框内写「详情拉取失败:<原因>」并说明这是按需源、坏了不影响监测;失败**不缓存**,下次点真重试(监测链路完全不受影响) |
| 任一轮失败 | 原因进事件表 `kind:'source_error'`(只在坏好的翻转那轮记,连坏不刷屏),让你能回看「哪天开始坏的」 |
源故障但留有旧数据时,表上方就地写「下面显示的是上次成功数据」—— 不把陈旧数据当实时数据念。
---
## 安装
### 方式 A:官方 CLI
```bash
dsh plugin --profile web add github:gh-gongjin/dsh-plugin-modelwatch
```
CLI 内部走 pnpm,需要 `pnpm` 在 PATH 里。本地目录同样可装:`dsh plugin --profile web add <本仓库的绝对路径>`。
### 方式 B:手工两步(不需要 pnpm)
1. 编辑 profile 的 `package.json`(默认 `~/.dsh/profiles/web/package.json`,Windows 为 `%USERPROFILE%\.dsh\profiles\web\package.json`):
- `dependencies` 增加:`"dsh-plugin-modelwatch": "link:<本仓库的绝对路径>"`
- `dsh.profile.bundles` 数组末尾追加 `"dsh-plugin-modelwatch"`
2. 在 profile 的 `node_modules` 下建目录联接:
```powershell
New-Item -ItemType Junction `
-Path "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-plugin-modelwatch" `
-Target "<本仓库的绝对路径>"
```
3. **重启 dsh**(宿主半边在启动时加载)。只改 `client.js` 的话刷新页面即可。
### 停用 / 卸载
临时停用:在 `~/.dsh/profiles/web/cordis.patch.yml` 里加
```yaml
- id: modelwatch
disabled: true
```
---
## 界面
`页头 → 页签栏(6) → 当前面板`,同一时刻只渲染一个面板。
页头左侧是标题;右侧是数据源状态 pill(「数据源全部在位 / X 故障 / 部分源未就位 / 等待首帧」,悬停 `title` 落故障原因)+ 检查时刻与模型总数 + **唯一的主操作**「立即检查」(跑时禁用并显示「检查中…」)。一个功能只留一个入口,总览页不再重复放这颗按钮。
| 页签 | id | 计数徽标 | 内容 |
|---|---|---|---|
| 总览 | `overview` | — | 数据源状态 → 本周前三领奖台 → 新上 / 变化各取前几条(默认页) |
| 热门周榜 | `top` | 榜内行数 | 完整榜单表(`# / 模型 / 周 token / 量级 / 较上轮`) |
| 免费榜单 | `free` | 榜内行数 | 与热门周榜共用一张表,数据来自独立的免费榜源,**各挂各的故障闸门** |
| 新上模型 | `new` | 近 N 天条数 | 新上表(5 列) |
| 变化记录 | `events` | 事件条数 | 时间线,限高内滚 |
| 设置 | `settings` | — | 三项偏好 + 只读运行环境 |
计数徽标只在有数据时渲染,数字**跟着快照走**,不写死。相对时间一律以**本轮检查时刻**为基准(`fmtAge(x, snap.at)`),不是浏览器当前时刻。
**点模型名看详情**(v1.12):页面里六处模型名都可点 —— 总览的「本周前三」slug、「近 N 天新上」名称、「最近变化」里带标识的行,新上模型表、热门周榜表、免费榜单表的模型列。点开居中的详情弹框(Esc / 点遮罩 / 「关闭」退出),显示上架日期、最大上下文与输出、模态、分词器、端点数、描述原文,以及**逐条端点**的上下文 / 最大输出 / 输入输出价 / 近 1 天可用率 / 量化(最多 12 条,其余写「另有 N 个端点未列出」)。
**弹框标题本身就是出口**(v1.14):标题点开后是这一模型在 OpenRouter 上的详情页(新标签,`rel="noopener noreferrer"`),旁边一枚 `↗` 独立成格 —— 长模型名被省略号截断时,它是唯一的「点了会离开本页」信号。地址由宿主按**源返回的 id** 拼好随详情送出(`detail.pageUrl`),跟的不是所点的榜单 slug(perma-slug 在详情页上是 404);加载中与拉取失败两态没有这个链接,因为那时 `pageUrl` 还不存在。
「端点」= 这个模型在 OpenRouter 上的一个上游供给(供应商 × 区域 × 部署),各自定价、各自报可用性 —— 源字段是 `endpoints`,v1.12 曾译作「线路」,用户 2026-10-07 真机截图指出这一列既看不懂又三行同名,故改口并把区分量画进列里:显示名取 `provider_name` + `tag` 斜杠后半段(`Amazon Bedrock · eu-west-1`),短名仍撞车的那几行改带完整 tag,源没给 tag 就只留供应商名(不编区分量);每格 `title` 保留原始 tag 作出处。列宽 200px 由无头实测钉(真夹具最长一条不截断),两侧同值由 client-31 对账。
详情是**按需源**:点开才向官方清单 API 的 `/models/<slug>/endpoints` 取一次,进程内缓存 10 分钟,同模型连点不再打上游;不进定时检查链路、不落 KV。源里没说明语义的字段(状态、时延、吞吐、折扣)一律不显示 —— 不猜。`~` 开头的别名标识会写明「指向当前版本、可能查不到公开端点」,与「这个模型没有公开端点」是两句不同的实话。
浮层只有详情这一处(v1 的「零浮层」为此改判):全树 `position:fixed` 有且只有 `.mw-mask` 一条规则,由 client-15 在 client.js 与原型两侧各数一遍条数。其余故障与状态说明照旧各占一行落在触发它的那张卡内,toast / 抽屉 / 浮动 tooltip 仍然禁止。
---
## 节拍与存储
默认 60 分钟一轮,档位是封闭集合 `[60, 360, 720, 1440]`;宿主 `ctx.timer` 优先,缺了退 `setInterval`(`unref` + dispose 显式清)。手动「立即检查」与定时走**同一条代码路径**,正在跑时返回 409 `CHECK_BUSY`。
存储走宿主 KV(domain `modelwatch`,三张表):
- `state`(key=`latest`):本轮快照 + 上轮榜单 + 近 30 天新模型行
- `events`(key=毫秒+序号):追加式流水,超出 `keepEvents` 裁老
- `prefs`(key=`prefs`):`{ intervalMin, topN, keepEvents }`,写入即生效(重挂定时器)
**KV 不可用时监控照跑**,但状态卡写「存储不可用:本轮变化无法留痕」,事件不落库 —— 不静默假装记录成功。
对外 HTTP 面挂在前缀 `/modelwatch` 下:`GET /api/snapshot`、`GET /api/stream`(SSE,首帧 snapshot、每轮 broadcast update)、`POST /api/check`、`GET /api/events`、`GET /api/model?slug=`(单模型详情,浏览器不直连 openrouter)、`GET/POST /api/prefs`。**回环闸门只拦 POST**(本插件对系统零破坏面,GET 留给你从浏览器直接看数据)。详情路由是本插件**第一次把外部源的字符串拼进上游 URL**,所以 slug 先过白名单闸门(不合法 400 且一次上游都不打),放行后才按段编码拼路径。
---
## 测试
零依赖:`node:assert/strict` + 自研 `check(name, fn)`,`node test/<x>.test.mjs` 直跑,无测试框架、无 npm script。用例名含「真机」者在 `SKIP_LOCAL=1` 下跳过并打印 SKIP。
```bash
for f in test/*.test.mjs; do node "$f"; done
```
当前读数(2026-10-07 本机):`SKIP_LOCAL=1` **165 过 / 0 挂 / 1 跳**(跳的是 `host-compat` 那条只声明口径、不碰本机的真机声明);带联网「真机」用例一起跑 **166 过 / 0 挂 / 0 跳**。
| 文件 | 项数 | 覆盖 |
|---|---|---|
| `api.test.mjs` | 27 | 路由表、回环闸门、SSE、错误码翻译、快照 free 切片与 `sources.free` 闸门、`GET /api/model`(命真夹具 / 脏 slug 400 且上游零请求 / 上游坏 502 且不进缓存 / dispose 清缓存 / 读操作不受回环闸 / **出口带 `pageUrl`**) |
| `check.test.mjs` | 21 | 变化判定(diff / `created` 现算 / 位次阈值)、三源各自独立闸门与翻转记账、单飞与 409 |
| `client.test.mjs` | 32 | 页签骨架与顺序、**tab→组件路由各挂各的源闸门**、计数跟快照走、版式契约(含金/银/铜徽标回落、`box-sizing` 根上必须在场、禁 `container-type`)、**原型同构断言**、**表头与列值逐列同向**(`.mw-tbl th` 的 `text-align:left` 特异性压过 `.mw-r`,必须有 `th.mw-r` 点名覆盖)、**列模板**(模型列封顶 340 + 末尾兜底列吃余量 + 1000px 窄屏回退,client 渲染树与原型逐列 deepEqual)、**六处模型名都可点**、**详情弹框三态与封顶/别名/尾注**、**端点列显示名四步改法**(拆 `tag` 后缀 / 短名撞车回落完整 tag / 不撞名不硬塞 / 空后缀不画,外加判重吃全量与每格 `title` 留源 tag)、**标题外链址=宿主给的 pageUrl 且加载/失败态不画链接**、**弹框接线钉源码**、**原型弹框与真组件逐格同构**(行、小结、列模板、表头类、端点列宽、标题区 CSS、两个外链属性 七张对账单) |
| `host-compat.test.mjs` | 11(+1 跳) | 缺 `storageDomain` / `timer` 时加载不抛、状态如实报 |
| `models.test.mjs` | 32 | 清单解析与规范化行;**详情源**:slug 闸门四类放行与逃逸拦截、逐段编码、规范化与四个语义未证字段不抽、`BAD_SHAPE` 两守卫、空壳端点行丢弃、四档失败折成人话、按需缓存(只存成功 / TTL / 插入序淘汰)、**详情页址 `pageUrl`**(按段转义、跟源 id 不跟所点 slug、空 id 不产出) |
| `rankings.test.mjs` | 26 | SSR:flight 反转义 → 平衡花括号扫描 → `queryKey` 定位,改版即失败的降级;免费榜源:JSON 筛选/口径回落/取最新日/截断 |
| `store.test.mjs` | 16 | 三张表读写语义、裁剪、clamp 用宿主常量不在客户端重写、`freeOk`/`freeError` 可选字段(旧档照读) |
除用例外的两件验收工具(`tmp/` 不入库,脚本按需在本地留存):详情弹框的无头几何与截图核对 `tmp/verify-detail.mjs`(**43 项判据,红 0 项**);变异电池 `tmp/mut_mw01.mjs`(**136 条全红**、0 漏网,跑完按字节还原 `allRestored:true`)。
---
## 目录结构
```
.
├── package.json 插件清单(dsh.bundle.patch / dsh.client),零第三方依赖
├── cordis.yml bundle patch:- insert: [{id: modelwatch, name: dsh-plugin-modelwatch}]
├── index.js 宿主半边:能力探测、建服务、注路由、注入浏览器半边所需载荷
├── client.js 浏览器半边:侧边栏入口 + 主面板(React,零构建,CSS 以字符串注入)
├── lib/
│ ├── kv-schema.js 自造记录校验器(宿主只调 parse/safeParse)
│ ├── kv-records-base.js domain 建域 / 写链 / 关停公共基座
│ ├── domain.js 事件 kind 标签、档位与边界常量的唯一来源
│ ├── stores.js state / events / prefs 三张表
│ ├── services/ models(清单)、rankings(榜单解析)
│ ├── check.js 变化判定(宿主单点)
│ ├── caps.js 宿主能力探测
│ └── api.js 路由表 + SSE + 回环闸门
├── docs/design-spec.md 设计规格(含 §9 每轮验收台账与踩坑记录)
├── prototype/index.html 可点原型(与 client.js 同构,改设计先改它)
└── test/ 见上
```
## 挂载时的两个坑(踩过,写在这里省你时间)
1. **宿主半边不许 import `@deepseek-ai/*`**:`link:` 挂载会解析出第二份模块实例。本仓库零第三方依赖,schema 与 KV 基座都是自造的最小实现。
2. **`client.js` 里 ModuleLoader factory 必须 `return module.exports`**:宿主把 factory 的**返回值**当模块导出。写成 `return module;` 会在宿主启动时报 `invalid plugin, expect function or object with an "apply" method, received object` —— 这个错从 renderer 抛出、转成主进程 crash 日志,排查要去前端产物里找同文案。
另外版式上几条硬约束(由 `client.test.mjs` 钉住,别改回去):`.mw-root` 必须**自己**带 `box-sizing:border-box`(`.mw-root *` 盖不到根自己,宿主前端没有任何通配 reset,漏了就整条右边界被裁出可视区);**禁用 `container-type` / `@container`**,断点只用 `@media`(现役 1080px 管两栏落单栏、1000px 管表格窄屏回退;容器查询在宿主 flex 主区下会把根算成 0 宽,整页塌成竖线);表格列模板 = **模型列封顶 340px + 末尾兜底空列吃余量**(`table-layout:fixed` 里没写宽的自动列会吸走 ~7 成表宽,把数值列推到天边 —— 真机大空隙事故;定宽列必须配 1000px 窄屏回退)。
---
## 许可
MIT。见 `LICENSE`。
榜单是第三方页面的非官方接口,随时可能因改版失效;插件不会假装数据是实时准确的。
Install
dsh plugin --profile web add github:gh-gongjin/dsh-plugin-modelwatch
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-plugin-modelwatch from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.