docs/ai-model-catalog.md
Rama master · réplica de lectura del repositorio original.
# AI Model Catalog
iTerm2's list of built-in AI models (context window, capabilities, the API/wire
protocol to use, and the recommended "latest" model per vendor) is data-driven.
Instead of hardcoded constants it is loaded from a JSON catalog that ships in the
app bundle and can be refreshed at runtime by downloading a newer, signed copy.
This lets new models reach users without an app update.
This document covers the moving parts, the runtime refresh + signing scheme, and
the process for publishing an update.
## Files and types
In this repo:
- `OtherResources/ai-models.json` - the bundled catalog (source of truth).
Schema: a top-level `{ "version": Int, "models": [ ... ] }`. Each model entry
is described in the header of `AIModelCatalog.swift` and the "ADDING A NEW
MODEL" checklist in `AIMetadata.swift`.
- `sources/AITerm/AIModelCatalog.swift` - loads the catalog. A private `Codable`
DTO decodes the JSON and maps it into the existing `AIMetadata.Model`. It
loads both the bundled snapshot and any downloaded cache and uses whichever
has the higher `version`. Entries whose `api`/`vendor` string the running app
does not understand are skipped (fail-safe), not fatal.
- `sources/AITerm/AIModelCatalogUpdater.swift` - the runtime refresher (see
below).
- `sources/AITerm/AIMetadata.swift` - unchanged public surface; its `models`
array and `recommended<Vendor>Model` / `alternate<Vendor>Models` accessors now
read from `AIModelCatalog`.
- `OtherResources/rsa_pub.pem` - the RSA public key used to verify downloaded
catalogs. Same key pair as the Python runtime download.
- `tools/copy_models.sh` - the publish script (see "Publishing").
Served from the website (`iterm2.com/downloads/ai/`):
- `models.json` - the catalog payload. This is `ai-models.json` from this repo,
copied verbatim. Stable URL.
- `manifest.json` - the signed manifest the app actually fetches. A JSON array
of entries `{ version, url, signature, minimum_ai_version?, maximum_ai_version? }`.
The catalog payload and the manifest are two different files because they have
different shapes: the payload is an object (`{version, models}`), the manifest is
an array of download candidates. The app's `aiModelCatalogURL` advanced setting
points at `manifest.json`.
## Three independent version numbers
Do not conflate these:
- **Catalog `version`** (in `ai-models.json` / each manifest entry) - a
monotonic data revision. The app installs a downloaded catalog only when its
version is greater than the bundled/cached one, and the manifest entry's
`version` must equal the version inside the payload it points at.
- **`AIModelCatalog.appCatalogCompatibilityVersion`** - a monotonic integer
baked into the app describing what the app's AI code can represent. Bump it
only for a change an older app could not consume gracefully (a new
`iTermAIAPI` serializer, a new `Model.Feature`, or a new `VectorStoreConfig`
value that alters how a model is represented or selected). A purely additive,
optional field older apps ignore when decoding (like `economyModel`) does not
warrant a bump - a server catalog using it should keep `minimum_ai_version`
low so older apps still adopt it and just skip the optimization. The manifest
gates each candidate on an integer `[minimum_ai_version, maximum_ai_version]`
range against this value. Using an integer (rather than parsing the app's
version string) is nightly/beta/adhoc-proof.
- **App version string** (`CFBundleShortVersionString`) - not used by the
catalog machinery at all.
A new *model* on an already-supported serializer is a pure data update. A new
*wire protocol* still needs an app build; a catalog that references it sets
`minimum_ai_version` to the compatibility version where that serializer landed,
so older apps skip that entry and fall back to the highest one they can consume.
## Economy models
A model entry may carry an optional `economyModel` naming a cheaper same-vendor
model (e.g. `claude-opus-4-8` -> `claude-haiku-5-5`). It is for high-frequency,
low-stakes work where the primary model is overkill and too expensive to run
repeatedly - specifically the orchestration screen-watch poller
(`ScreenWatchPoller`), which asks a model every few seconds whether a session has
reached a described condition.
`AIMetadata.economyModel(for:)` resolves a model to its economy alternative
(returning nil when there is none). It works in both "use recommended" mode and
manual mode: if the passed model carries no pointer (settings-built models do
not), it falls back to the catalog entry of the same name.
Crucially, the resolved economy model **preserves the caller's transport** (its
`url` and `api`, hence which host the API key is sent to) and swaps only the
model identity and size limits. A manual-mode user may point a catalog-named
model at a custom base URL (corporate proxy / gateway); resolving to the economy
entry's own public endpoint would bypass that proxy and leak the API key to the
public vendor host. Same-vendor guarantees the economy model speaks the same
protocol, so forcing it onto the configured endpoint is safe.
Because of this, `ScreenWatchPoller` sets `AIConversation.modelOverride` (a full
model used verbatim), not `AIConversation.model` (a name that
`AIConversation.complete` re-resolves against the public catalog). An absent
economy model leaves the poller on the configured chat model.
## Runtime refresh
`AIModelCatalogUpdater` (a singleton, `@objc(iTermAIModelCatalogUpdater)`) runs
silently, with no UI beyond the one-time consent prompt:
1. Download the manifest from `aiModelCatalogURL`.
2. Filter entries to those whose `[minimum_ai_version, maximum_ai_version]` range
admits this build and whose `version` is newer than what is already loaded,
ordered highest-version-first.
3. Download the top candidate's payload.
4. Verify the payload's RSA signature (`iTermSignatureVerifier`,
RSA-PKCS#1-v1.5 over SHA-256) against the bundled `rsa_pub.pem`.
5. Validate that it decodes to at least one usable model, satisfies the catalog
invariants (see `AIModelCatalog.validate`: every `economyModel` resolves to a
same-vendor non-self entry; at most one recommended per vendor), and that its
version matches the manifest entry, then atomically replace the cached
catalog.
If a candidate fails at any of steps 3-5, the updater falls through to the next
candidate, so one broken top entry (dead URL, botched signing) doesn't stall
updates when an older-but-still-newer good entry exists in the same manifest. The
refreshed catalog takes effect on the next launch. On any failure the previous
cached/bundled catalog is left untouched.
### When it runs
`performPeriodicCheck()` is invoked at app launch (`applicationDidFinishLaunching`)
and again the moment AI is turned on (via the `iTermSecureUserDefaults.didChange`
notification for the `enableAI` key), so a freshly-enabled user does not wait for
a relaunch. It is rate-limited to once per day
(`iTermPersistentRateLimitedUpdate`), and self-gating, so calling it from both
places is idempotent.
### Gating and consent
Downloading contacts the network, which changes the privacy model, so
`performPeriodicCheck` applies three gates in order:
1. **AI must be fully enabled**: allowed in advanced settings and in the secure
setting (both covered by `iTermAITermGatekeeper.allowed`) and the plugin
installed (`iTermAITermGatekeeper.pluginInstalled()`). If AI is not set up we
never download and never even ask.
2. **A non-empty, valid `aiModelCatalogURL`**: clearing it disables checking
entirely, as the advanced-setting help promises. This is checked BEFORE
consent so a cleared URL also suppresses the consent prompt (rather than
asking permission for a download that can never happen). A non-empty URL does
not by itself enable anything; the other gates still apply.
3. **One-time consent**: a tri-state
`iTermUserDefaults.aiModelCatalogUpdateConsent` (`.unknown` / `.granted` /
`.denied`, stored under a `NoSync` key). On first eligible check we present a
modal; the answer is remembered so we never ask again. A dismissal leaves it
`.unknown` so we ask again rather than silently disabling updates. An
in-flight guard prevents stacking a second modal if a queued check re-enters
while the prompt is open.
## Signing
The signature is a detached RSA signature over the raw bytes of the payload,
base64-encoded:
```
openssl dgst -sha256 -sign "$RSA_PRIV" -out models.sig models.json
openssl base64 -A -in models.sig # the signature string
# self-check against the shipped public key:
openssl dgst -sha256 -verify OtherResources/rsa_pub.pem -signature models.sig models.json
# -> "Verified OK"
```
`$RSA_PRIV` is the private key matching `OtherResources/rsa_pub.pem` (the Python
runtime signing key; not in the repo). The signature goes in the manifest entry's
`signature` field, not in the payload. The bundled `ai-models.json` is not
signature-checked - its integrity comes from the app's own code signature; only
downloaded payloads are verified.
Gotchas:
- Sign the exact bytes you serve. Any reformatting after signing breaks the
digest. Sign last.
- The manifest entry's `version` must equal the top-level `version` inside the
payload, or the app rejects the download.
- The manifest itself is unsigned; that is fine, because nothing it points at can
be installed without a valid payload signature, and it is fetched over HTTPS.
## Publishing
`tools/copy_models.sh` bridges this repo (source of truth) and the website repo
(`~/iterm2-website`, which serves iterm2.com). It copies the payload, signs it,
updates the manifest, and pushes both live via `scp` to the `bryan` host (the
same mechanism as `update-patrons.sh`).
Full release process (the script automates steps 4-5):
1. Edit `OtherResources/ai-models.json`. Capture any new refusal fixtures the
tests require (see the "ADDING A NEW MODEL" checklist in `AIMetadata.swift`).
2. Bump the top-level `version`. Mandatory: the app only installs a higher
version, and it must match the manifest entry.
3. Build and ship the app so the bundled snapshot matches (an app upgrade always
wins over a stale cache of the same-or-lower version).
4. Run the publish script:
```
RSA_PRIV=/path/to/rsa_priv.pem tools/copy_models.sh
```
It writes `~/iterm2-website/downloads/ai/models.json` and merges an entry into
`manifest.json` (replacing this version's entry, keeping others), self-verifies
the signature against `rsa_pub.pem`, `scp`s both files to
`bryan:iterm2.com/downloads/ai/`, and opens the Cloudflare cache page so you
can purge the two URLs. Set `AI_MIN_VERSION=<int>` to raise the entry's
`minimum_ai_version` when the catalog needs a newer app.
5. Commit the website repo (`models.json` + `manifest.json`) so the deploy is
reproducible.
Because both files sit at stable URLs, a CDN can serve stale copies; purge the
Cloudflare cache for `models.json` and `manifest.json` after pushing. If a stale
payload is briefly served against a newer manifest, the version-mismatch check
rejects it safely (no update until the cache clears) rather than installing
anything wrong.