Bundle
dsh-catalog-refresh
Rebuild DSH's pi-ai model catalogues at runtime: fetches the live model lists from OpenRouter, OpenCode Zen, Fireworks, Groq, Together and DeepSeek and patches the harness's installed pi-ai MODELS registry, so the model picker and discovery show fresh models without hand-adding custom models.
- Source
- joshryandavis
- License
- MIT
- Updated
- Updated 2 days ago
Readme
English | [中文](README.zh.md)
# dsh-catalog-refresh
Rebuild the model catalogues DSH serves at runtime, so OpenRouter, OpenCode,
Fireworks, and friends show the **live** model lists instead of the stale
snapshot baked into the `@earendil-works/pi-ai` version DSH pins.
## Why this exists
DSH gets every LLM provider model list from the `@earendil-works/pi-ai`
package it bundles: `dsh-llm-pi-ai` reads pi-ai's static `MODELS` registry
(`dist/providers/data/*.json`, generated when pi-ai publishes). The model
picker, the Models page discovery probe, and request routing all serve that
baked-in list — so when OpenRouter adds a model after a pi-ai release, DSH
users must hand-add custom models until DSH bumps pi-ai.
This plugin fetches the providers' own live model endpoints at boot (and
then on a cadence), converts them into pi-ai `Model` entries, and patches
the harness's pi-ai `MODELS` registry in place — the same module instance
`dsh-llm-pi-ai` reads. Patching `MODELS` alone is not enough for the model
picker: `dsh-llm-pi-ai` materializes each route's model list when its
settings section changes, so the plugin also writes a per-refresh stamp
into the declared providers' `headers` in the `llm-pi-ai` settings section
(a deep merge; nothing configured is touched). That makes `dsh-llm-pi-ai`
re-resolve its routes against the patched registry, so the picker updates
live — no restart required.
## Sources
| provider | endpoint | auth | detail |
| ---------- | ----------------------------------------------- | --------- | ------------- |
| openrouter | `https://openrouter.ai/api/v1/models` | public | full (pricing, context, max tokens, reasoning, modalities) |
| opencode | `https://opencode.ai/zen/v1/models` | public | id list; installed metadata kept, new ids routed by prefix |
| fireworks | `https://api.fireworks.ai/inference/v1/models` | API key | id + context, vision, chat flags |
| groq | `https://api.groq.com/openai/v1/models` | API key | id + context, max tokens, modalities |
| together | `https://api.together.ai/v1/models` | API key | id list; installed metadata kept |
| deepseek | `https://api.deepseek.com/models` | API key | id list; installed metadata kept |
Key-gated sources use the conventional environment variable
(`FIREWORKS_API_KEY`, `GROQ_API_KEY`, `TOGETHER_API_KEY`, `DEEPSEEK_API_KEY`)
or the credential record DSH's Models page stores (`llm-pi-ai/<provider>`).
Without a key the source is skipped and the installed catalogue stays in
place.
## How it patches
1. Locates the pi-ai package the running harness imports, anchored on the
`dsh` CLI entry that started the process (`process.argv[1]` →
`@deepseek-ai/dsh-llm-pi-ai` → `@earendil-works/pi-ai`).
2. Imports that package's `dist/models.generated.js` by file URL — Node's
module cache keyed by URL makes it the **same instance** `dsh-llm-pi-ai`
reads via `@earendil-works/pi-ai/providers/all`.
3. Replaces `MODELS[provider]` with the rebuilt map. Discovery calls read
the registry live, so the Models-page probe is immediately fresh.
4. **Nudges the picker.** `dsh-llm-pi-ai` only re-materializes a route's
model list when its settings section changes, and the picker
(`llm.models` / `session.models`) serves those materialized lists. So
after every patch the plugin writes a fresh stamp into each *declared*
provider's `headers` in the `llm-pi-ai` settings section (`settings.yaml`
gains an `x-catalog-refresh: <timestamp>` entry — inert, and the deep
merge leaves every other field intact). The section change makes
`dsh-llm-pi-ai` re-resolve its routes against the patched registry, so
the picker shows the rebuilt catalogue immediately.
Boot runs the cache round first (no network, so the picker resolves fresh
data as early as possible), then the live round; both nudge. The stamp is
fingerprint-gated: a provider is only nudged when its model set actually
changed (or once per boot), so `settings.yaml` is not rewritten on every
restart.
Merging rules: for OpenRouter the live entry wins (pricing, context, max
tokens, reasoning, modalities) while curated fields (`compat`,
`thinkingLevelMap`) carry over from the installed entry when present. For
id-list sources, installed entries stay authoritative for curated fields
(`compat`, `thinkingLevelMap`, reasoning, cost, wire protocol) while the
live listing refreshes the **structural facts** the endpoint reports
(context window, max tokens, input modalities, name) — Fireworks and Groq
both publish these — and unknown ids are added with structural defaults.
Entries the endpoint reports as non-chat (speech/audio output such as Groq's
whisper/orpheus) are dropped: they cannot serve chat traffic.
Fetched lists are cached under `$DSH_HOME/catalog-refresh/`; a restart
without network still applies the last good refresh.
## Reasoning-effort support
`dsh-llm-pi-ai` only shows the composer's reasoning-effort selector for
models that declare effort metadata. The pi-ai catalogue ships none, and the
`dsh-thinking-effort` plugin only covers *hand-declared* settings models — so
catalogue-served models lost the selector after the rebuild. This plugin
bakes a default `thinkingLevelMap` into every rebuilt reasoning model,
mirroring `dsh-thinking-effort`'s official preset:
- Off / **High** / **Max** for reasoning models (OpenRouter's
`openai-completions` wire receives `reasoning: { effort: "high" | "max" }`),
- no effort control for non-reasoning models (same as before).
The offered levels come from `CATALOG_REFRESH_EFFORTS` (comma-separated level
ids; `off` is always supported). For example
`CATALOG_REFRESH_EFFORTS=off,low,medium,high` offers Off/Low/Medium/High
instead. Custom gateway spellings (e.g. sending `ultra` for High) still need
per-model `reasoningEfforts` in the `llm-pi-ai` settings document — this
plugin only provides the catalogue default.
Id-list sources (opencode, fireworks, groq, together, deepseek) return model
ids only — no capability metadata. Known ids keep the installed catalogue's
flags; **unknown ids assume reasoning-capable** (the overwhelming case for
chat models), so new models like Fireworks `glm-5p3` get the effort selector
immediately. Definitively non-reasoning families (Fireworks
`*-embedding-*` / `*-reranker-*`) are excluded and never advertise effort.
**Fireworks wire note.** Fireworks validates Anthropic-style
`thinking.budget_tokens` strictly and rejects requests where
`max_tokens` is not *strictly* greater than the budget (a 400 this plugin's
effort support surfaced). The plugin therefore routes **all** Fireworks
models through Fireworks' native OpenAI-compatible endpoint with
`reasoning_effort` (`low`/`medium`/`high`/`max` all accepted), which avoids
budget validation entirely. `Off` on a Fireworks reasoning model maps to the
provider default — Fireworks rejects disabling thinking on thinking models,
so that is the closest equivalent.
## Configuration (environment)
| variable | meaning | default |
| ------------------------------- | ---------------------------------------- | ------------ |
| `CATALOG_REFRESH_DISABLE` | `1` disables the plugin entirely | off |
| `CATALOG_REFRESH_INTERVAL_HOURS`| re-fetch cadence | 12 |
| `CATALOG_REFRESH_HOME` | cache directory override | `$DSH_HOME` or `~/.dsh` |
| `CATALOG_REFRESH_EFFORTS` | offered reasoning levels for rebuilt models | `off,high,max` |
## Install
```sh
dsh plugin --profile <profile> add dsh-catalog-refresh
```
Boot output shows one line per source:
```
[dsh-catalog-refresh] openrouter: patched 396 models (live)
[dsh-catalog-refresh] opencode: patched 67 models (live)
[dsh-catalog-refresh] fireworks: skipped (… answered 401)
```
## Operational notes
- **pnpm copies the package into the profile at install time.** After editing
the plugin source, run `pnpm install` again in the profile (the profile's
`node_modules/dsh-catalog-refresh` is a snapshot, not a symlink),
then restart `dsh web`.
- **Key-gated sources** (fireworks, groq, together, deepseek, …) use the
conventional environment variable or the credential record DSH's Models page
stores (`llm-pi-ai/<provider>`).
- The patch is live only in the running process; restarting the app re-runs
the refresh (cached lists apply first if the network is unavailable).
- **`settings.yaml` gains an `x-catalog-refresh` header per declared
provider** — this is the re-resolution nudge, not a configuration change.
It is safe to delete; the next refresh rewrites it.
## Development
```sh
npm run check # syntax-check the modules
npm test # unit tests (conversion, merge, cache-first patch, nudge) — no network, no machine-specific paths
```
Install
dsh plugin --profile web add github:joshryandavis/dsh-catalog-refresh#59de7e226347f61b381c947a73c6e9f842d241d2
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-catalog-refresh from the hub