# dsh-catalog-refresh

在运行时重建 DSH 提供的模型目录，让 OpenRouter、OpenCode、Fireworks 等
服务显示**最新的**模型列表，而不是 DSH 所固定的 `@earendil-works/pi-ai`
版本里内置的过期快照。

## 为什么需要它

DSH 的所有 LLM 提供方的模型列表都来自它所捆绑的 `@earendil-works/pi-ai`
包：`dsh-llm-pi-ai` 读取 pi-ai 内置的静态 `MODELS` 注册表
（`dist/providers/data/*.json`，在 pi-ai 发布时生成）。模型选择器、
Models 页面的发现探测以及请求路由都使用这份内置列表——所以当 OpenRouter
在 pi-ai 发布之后新增模型时，在 DSH 升级 pi-ai 之前，用户只能手工添加
自定义模型。

这个插件在启动时（以及之后按固定节奏）抓取各提供方自己的实时模型端点，
把它们转换成 pi-ai 的 `Model` 条目，并原地修补 harness 的 pi-ai
`MODELS` 注册表——也就是 `dsh-llm-pi-ai` 读取的同一个模块实例。仅仅修补
`MODELS` 还不足以刷新模型选择器：`dsh-llm-pi-ai` 只在设置分区变化时才会
重新物化每条路由的模型列表，所以插件还会往 `llm-pi-ai` 设置分区中已声明
提供方的 `headers` 里写入一个每次刷新都不同的时间戳（深度合并，不触碰
任何已配置字段）。这会让 `dsh-llm-pi-ai` 针对修补后的注册表重新解析路由，
选择器无需重启即可实时更新。

## 数据源

| provider   | endpoint                                        | auth      | detail        |
| ---------- | ----------------------------------------------- | --------- | ------------- |
| openrouter | `https://openrouter.ai/api/v1/models`           | public    | 完整（定价、上下文、最大 token、推理、模态） |
| opencode   | `https://opencode.ai/zen/v1/models`             | public    | 仅 id 列表；已安装元数据保留，新 id 按前缀路由 |
| fireworks  | `https://api.fireworks.ai/inference/v1/models`  | API key   | id + 上下文、视觉、对话标记 |
| groq       | `https://api.groq.com/openai/v1/models`         | API key   | id + 上下文、最大 token、模态 |
| together   | `https://api.together.ai/v1/models`             | API key   | 仅 id 列表；已安装元数据保留 |
| deepseek   | `https://api.deepseek.com/models`               | API key   | 仅 id 列表；已安装元数据保留 |

需要密钥的数据源使用常规环境变量
（`FIREWORKS_API_KEY`、`GROQ_API_KEY`、`TOGETHER_API_KEY`、`DEEPSEEK_API_KEY`）
或 DSH Models 页面保存的凭据记录（`llm-pi-ai/<provider>`）。没有密钥时该
数据源会被跳过，已安装的目录保持不变。

## 如何修补

1. 定位运行中的 harness 所导入的 pi-ai 包，以启动进程的 `dsh` CLI 入口为
   锚点（`process.argv[1]` → `@deepseek-ai/dsh-llm-pi-ai` →
   `@earendil-works/pi-ai`）。
2. 通过文件 URL 导入该包的 `dist/models.generated.js`——Node 以 URL 为键
   的模块缓存会保证它与 `dsh-llm-pi-ai` 通过
   `@earendil-works/pi-ai/providers/all` 读取的是**同一个实例**。
3. 用重建后的映射替换 `MODELS[provider]`。发现调用实时读取注册表，因此
   Models 页面的探测立即返回新列表。
4. **推动选择器重新解析。** `dsh-llm-pi-ai` 只在设置分区变化时才会重新
   物化路由的模型列表，而选择器（`llm.models` / `session.models`）服务
   的正是这些物化后的列表。因此每次修补后，插件都会向 `llm-pi-ai` 设置
   分区中*已声明*的每个提供方的 `headers` 写入一个新时间戳
   （`settings.yaml` 中会出现 `x-catalog-refresh: <时间戳>` 条目——无实际
   作用，深度合并不会改动其他字段）。分区变化会让 `dsh-llm-pi-ai` 针对
   修补后的注册表重新解析路由，选择器立即显示重建后的目录。

启动时先执行缓存轮（不联网，尽早让选择器解析到最新数据），再执行实时轮；
两轮都会触发推动。时间戳按指纹门控：只有当某提供方的模型集合确实变化时
（或每次启动首次）才会推动，因此 `settings.yaml` 不会在每次重启时被重写。

合并规则：对于 OpenRouter，实时条目优先（定价、上下文、最大 token、推理、
模态），而已安装条目中的精选字段（`compat`、`thinkingLevelMap`）在存在时
会保留。对于仅 id 列表的数据源，已安装条目在精选字段（`compat`、
`thinkingLevelMap`、推理、成本、线上协议）上保持权威，而实时列表会刷新
端点报告的**结构事实**（上下文窗口、最大 token、输入模态、名称）——
Fireworks 和 Groq 都会发布这些数据——未知 id 以结构默认值加入。端点报告为
非对话模型的条目（语音/音频输出，如 Groq 的 whisper/orpheus）会被剔除。

抓取的列表缓存在 `$DSH_HOME/catalog-refresh/` 下；在没有网络的情况下重启
仍会应用最后一次成功的刷新。

## 推理档位支持

`dsh-llm-pi-ai` 只为声明了档位元数据的模型显示 Composer 的推理档位选择器。
pi-ai 内置目录没有任何档位元数据，而 `dsh-thinking-effort` 插件只处理
*手工声明*的设置模型——所以目录重建后，目录托管的模型失去了档位选择器。
本插件会为每个重建后的推理模型写入默认 `thinkingLevelMap`，与
`dsh-thinking-effort` 的官方预设一致：

- 推理模型提供 Off / **High** / **Max**（OpenRouter 的
  `openai-completions` 线上请求会收到 `reasoning: { effort: "high" | "max" }`），
- 非推理模型不提供档位控制（与之前一致）。

可提供的档位由 `CATALOG_REFRESH_EFFORTS` 控制（逗号分隔的档位 id；`off`
始终支持）。例如 `CATALOG_REFRESH_EFFORTS=off,low,medium,high` 会提供
Off/Low/Medium/High。自定义线上值（例如 High 发送 `ultra`）仍需要在
`llm-pi-ai` 设置文档里按模型写 `reasoningEfforts`——本插件只提供目录默认值。

仅 id 列表的数据源（opencode、fireworks、groq、together、deepseek）只返回
模型 id，不返回能力元数据。已知 id 沿用已安装目录的标记；**未知 id 默认
按推理模型处理**（聊天模型绝大多数都是），因此 Fireworks 的 `glm-5p3` 等
新模型会立即获得档位选择器。明确非推理的模型家族（Fireworks 的
`*-embedding-*` / `*-reranker-*`）会被排除，绝不展示档位。

## 配置（环境变量）

| 变量                            | 含义                                  | 默认值        |
| ------------------------------- | ------------------------------------- | ------------- |
| `CATALOG_REFRESH_DISABLE`       | 设为 `1` 完全禁用该插件                | 关闭          |
| `CATALOG_REFRESH_INTERVAL_HOURS`| 重新抓取的节奏（小时）                 | 12            |
| `CATALOG_REFRESH_HOME`          | 缓存目录覆盖                           | `$DSH_HOME` 或 `~/.dsh` |
| `CATALOG_REFRESH_EFFORTS`       | 重建模型提供的推理档位                   | `off,high,max` |

## 安装

```sh
dsh plugin --profile <profile> add dsh-catalog-refresh
```

启动输出中每个数据源一行：

```
[dsh-catalog-refresh] openrouter: patched 396 models (live)
[dsh-catalog-refresh] opencode: patched 67 models (live)
[dsh-catalog-refresh] fireworks: skipped (… answered 401)
```

## 运维说明

- **pnpm 安装时会把包复制进 profile。** 修改插件源码后需要在 profile 中
  再次运行 `pnpm install`（profile 的 `node_modules/dsh-catalog-refresh`
  是快照而不是符号链接），然后重启 `dsh web`。
- **需要密钥的数据源**（fireworks、groq、together、deepseek 等）使用常规
  环境变量，或 DSH Models 页面保存的凭据记录（`llm-pi-ai/<provider>`）。
- 修补只对运行中的进程生效；重启应用会重新执行刷新（网络不可用时优先
  应用缓存列表）。
- **`settings.yaml` 中每个已声明提供方会多出一个 `x-catalog-refresh`
  header**——这是触发重新解析的机制，不是配置变更。可以安全删除；下次
  刷新会自动重写。

## 开发

```sh
npm run check   # 语法检查各模块
npm test        # 单元测试（转换、合并、缓存优先修补、nudge）——无需网络，无机器相关路径
```
