Skip to content

Move the provider model lists into a checked-in JSON catalog - #23

Merged
aaroncoville merged 3 commits into
mainfrom
upstream/model-catalog-json
Aug 26, 2026
Merged

aaroncoville merged 3 commits into
mainfrom
upstream/model-catalog-json

Conversation

@aaroncoville

@aaroncoville aaroncoville commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

What & why

Every provider's model picker was a hardcoded ModelOption[] in the renderer store, so
shipping a model — one string — meant editing renderer source, type-checking and
rebuilding. The arrays also could not say which releases a model belongs to: a build
whose CLI never shipped a model still offered it, and a retired model stayed in the
picker forever.

The twelve arrays now live in src/shared/modelCatalog.json, ported verbatim, each entry
carrying inclusive minAppVersion / maxAppVersion bounds. Every ported entry has null
for both, so this build offers exactly what it offered before — the version fields are the
mechanism, not a behaviour change. The catalog is imported at build time (no fs, no
network, offline-safe) and modelsForProvider filters it by the running app version, which
electron-vite already inlines into the renderer as __APP_VERSION__ for the update badge.
Adding a model is now a one-line JSON edit.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs
  • Build / CI

Evidence

No visible UI change: the pickers offer the same models, in the same order, with the same
labels. The evidence is the test suite going red → green, plus proof that the built bundle
really carries the version into the filter.

Reproduce both halves:

node --test test/model-catalog.test.cjs                                        # green
git checkout <base> -- src/renderer/src/store/config.ts test/load-ts.cjs
node --test test/model-catalog.test.cjs                                        # red

Before

The catalog-backed API does not exist, so the version-bound assertions fail. The three
tests that do pass are the ones pinning today's picker output — they pass against the
hardcoded arrays, which is what makes them a faithful record of what shipped:

CleanShot 2026-08-26 at 14 58 15@2x

After

Same file, same assertions — the picker-output tests still pass (so nothing a user sees
changed), and the version filter now works:

CleanShot 2026-08-26 at 14 58 46@2x And the production bundle proves the version reaches the filter — esbuild folds the build-time define straight through, so there is no round trip to main:
$ npm run build && grep -n -A3 'function runningAppVersion' out/renderer/assets/index-*.js
40935:function runningAppVersion() {
40936-  return "0.4.5";
40937-}
40938-function offeredAtVersion(model, appVersion) {

How I tested it

  • OS: macOS 15 (Darwin 25.6.0), Node 22
  • Steps:
    • npm run typecheck — 0 errors (node + web).
    • npm run test:focused — 560 of 560 pass; the base commit is 552 of 552, and the
      8 new tests are the whole difference.
    • npm run build — succeeds; bundle checked as above.
    • Mutation-checked the new tests: disabling the minAppVersion check, dropping the
      maxAppVersion check, editing one catalog label, and making the version accessor
      ignore __APP_VERSION__ each turn the matching test red.

Checklist

  • Before and after evidence is attached above, under both headings.
  • npm run typecheck passes.
  • npm run test:focused passes.
  • npm run build succeeds.
  • This PR is one change. Unrelated fixes belong in their own PR.
  • I read the diff myself before opening this, and there is no debug output,
    commented-out code, or unrelated formatting churn in it.
  • Any new UI derives from DESIGN.md / tokens.ts — no ad-hoc colors, spacing,
    or fonts. (No UI in this change.)
  • If I added art, it's my own or compatibly licensed, and listed in
    ATTRIBUTION.md. (No art.)

How the catalog works & how to maintain it

The provider model lists live in src/shared/modelCatalog.json and are imported
at build time. modelsForProvider(provider) returns that provider's list,
filtered at runtime by the running app version.

There are two independent "version" concepts — they do not map to each other:

  • Top-level "version" is the file schema version. Nothing reads it today;
    it exists so that if the file's structure ever changes, code can branch on
    it. Bump it only when the shape of the JSON changes — never for adding or
    removing a model.
  • Per-model minAppVersion / maxAppVersion are the app-version bounds
    (inclusive; null = unbounded). A model is shown only when the running app
    version falls within its bounds. Every model currently ships null/null, so
    the picker is identical to the previous hardcoded lists.

Add a new model

Add an entry under the provider in modelCatalog.json:

{ "id": "gpt-6-nova", "label": "GPT-6 Nova", "minAppVersion": null, "maxAppVersion": null }

If the model only works from a future release, set minAppVersion to that
release (e.g. "0.5.0") so users on older builds — whose CLI can't run it —
don't see it. Otherwise leave both bounds null.

Retire an old model

Set maxAppVersion to the last release that should still offer it:

{ "id": "gpt-5.6-sol", "label": "GPT-5.6 Sol", "minAppVersion": null, "maxAppVersion": "0.4.9" }

Builds newer than 0.4.9 stop offering it, while users still on 0.4.x keep
seeing it. To remove a model everywhere at once (all versions), just delete
its entry instead.

Aaron Coville added 2 commits August 26, 2026 14:00
The node:test TypeScript loader hands every resolved local import to
ts.transpileModule, including `.json` ones. TypeScript cannot emit for JSON
input and throws "Debug Failure. Output generation failed", so any module that
imports a JSON file was untestable, and the failure surfaced as a crashed test
file rather than a readable error.

The app compiles with resolveJsonModule and the bundler parses JSON imports
directly, so do the same here: JSON.parse the file instead of transpiling it.
Every provider's model picker was a hardcoded ModelOption[] in the renderer
store, so shipping a model — one string — meant editing renderer source,
type-checking and rebuilding. The arrays also could not say which releases a
model belongs to: a build whose CLI never shipped a model still offered it, and
a retired model stayed in the picker forever.

The twelve arrays now live in src/shared/modelCatalog.json, ported verbatim,
and each entry carries inclusive minAppVersion/maxAppVersion bounds. Every
ported entry has null for both, so this build offers exactly what it offered
before. The catalog is imported at BUILD time — no fs, no network, offline-safe
— and modelsForProvider filters it by the running app version, which
electron-vite already inlines into the renderer as __APP_VERSION__ for the
update badge (esbuild folds the accessor down to `return "0.4.5"`, so no round
trip to main is involved). An unparseable bound or version is ignored rather
than hiding the model: a picker that silently loses every model is far worse
than one offering a model the CLI cannot run, and the command field stays
editable either way.

Adding a model is now a one-line JSON edit, and a release can introduce or
retire one without touching TypeScript. The comments that explained why each
list looked the way it did are kept above the import, since JSON cannot carry
them.
@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

✅ Evidence received. Before and after are both attached. Thanks — this is what makes a PR reviewable in one pass.

# Conflicts:
#	src/renderer/src/store/config.ts
@aaroncoville
aaroncoville merged commit 1643003 into main Aug 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant