# 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 `recommendedModel` / `alternateModels` 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=` 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.