From 3f864ec2542490af32588d750371caa237d80bf7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 17:30:49 -0700 Subject: [PATCH 1/8] fix: build release binaries against the live models.dev catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.github/workflows/release.yml` built every platform binary with `MODELS_DEV_API_JSON: test/tool/fixtures/models-api.json`, so each shipped binary embedded a checked-in test fixture as its bundled models.dev catalog instead of a release-time one. The fixture's newest entry is `2026-03-30`. Verified against the shipped `0.9.7` binary in an isolated `HOME`: its snapshot-only `openai` set is exactly the fixture's (46 of 47 ids, missing only `gpt-5-chat-latest`, which is filtered elsewhere), with zero ids the fixture does not have. That catalog still offers 11 ids the live catalog has dropped (`gpt-5-codex`, `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5.2-codex`, `codex-mini-latest`, `o1-mini`, `o1-preview`, and three others) and is missing `gpt-5.5` plus every `gpt-5.6` variant. It carries 105 providers against the live catalog's 207, so the staleness is not OpenAI-specific. Upstream's `publish.yml` sets no such override and fetches live at release time; `git log -S` shows ours has been there since the initial fork commit `f2cd5c1245` with no explanatory comment, and reads as copy-paste from the CI job where a hermetic build genuinely is correct. Removing the override makes release builds depend on models.dev, so `build.ts` now validates the payload before writing the snapshot: - non-2xx responses throw instead of flowing an error page into the snapshot (`fetch` resolves for 4xx/5xx, and a JSON error body is valid TypeScript that would have shipped as a catalog with no providers) - the payload must parse, be a provider object, carry at least 50 providers, and include `anthropic`, `openai` and `google` `ci.yml` and `pre-release-check.ts` keep the fixture pin — a hermetic build is correct there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- .github/workflows/release.yml | 7 ++++- packages/opencode/script/build.ts | 52 +++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d481ee55c..9d65e23a06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,12 @@ jobs: OPENCODE_CHANNEL: ${{ contains(github.ref_name, '-') && 'beta' || 'latest' }} OPENCODE_RELEASE: "1" GH_REPO: ${{ env.GH_REPO }} - MODELS_DEV_API_JSON: test/tool/fixtures/models-api.json + # altimate_change — MODELS_DEV_API_JSON is deliberately NOT set here. + # Pointing it at test/tool/fixtures/models-api.json (as ci.yml does, where + # a hermetic build is correct) made every shipped binary embed that fixture + # as its bundled models.dev catalog — newest entry 2026-03-30. Release + # builds fetch models.dev live, matching upstream's publish.yml. build.ts + # validates the payload and fails the build if it is unusable. # Smoke-test: verify the compiled binary actually starts. # Only possible for native linux-x64 builds on the ubuntu runner. diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 1f5796fd8b..5b65e68d6c 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -28,10 +28,58 @@ const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).t console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" -// Fetch and generate models.dev snapshot + +// A models.dev catalog small enough to trip this is a fetch that went wrong, not +// a real shrink: the live catalog carries 200+ providers and the checked-in test +// fixture carries 105. The named providers are the ones whose absence would make +// a shipped binary visibly broken. +const MIN_CATALOG_PROVIDERS = 50 +const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] + +/** Fetch the models.dev catalog, failing loudly on a non-2xx. + * + * `fetch` resolves for 4xx/5xx, so without this check a load-balancer error page + * flows straight into the snapshot. An HTML body would at least break the build + * at parse time, but a JSON error body (`{"error": ...}`) is valid TypeScript and + * would ship as a catalog with no providers in it. */ +async function fetchModelsCatalog(url: string): Promise { + const res = await fetch(url) + if (!res.ok) throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${url}`) + return await res.text() +} + +/** Reject a catalog that parses but is obviously not usable. + * + * Release builds embed this in every binary, so an empty or truncated payload + * has to stop the release rather than ship a CLI that offers no models. */ +function assertUsableCatalog(text: string, origin: string): void { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (e) { + throw new Error(`models.dev catalog from ${origin} is not valid JSON`, { cause: e }) + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + throw new Error(`models.dev catalog from ${origin} is not a provider object`) + const providers = Object.keys(parsed) + if (providers.length < MIN_CATALOG_PROVIDERS) + throw new Error( + `models.dev catalog from ${origin} has only ${providers.length} providers, expected at least ${MIN_CATALOG_PROVIDERS}`, + ) + const missing = REQUIRED_CATALOG_PROVIDERS.filter((p) => !providers.includes(p)) + if (missing.length > 0) + throw new Error(`models.dev catalog from ${origin} is missing required providers: ${missing.join(", ")}`) + console.log(`models.dev catalog from ${origin}: ${providers.length} providers`) +} + +// Fetch and generate models.dev snapshot. MODELS_DEV_API_JSON pins the catalog to +// a local file for hermetic builds (ci.yml, pre-release-check.ts); release builds +// leave it unset so the shipped binary embeds a release-time catalog. +const modelsOrigin = process.env.MODELS_DEV_API_JSON ?? `${modelsUrl}/api.json` const modelsData = process.env.MODELS_DEV_API_JSON ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() - : await fetch(`${modelsUrl}/api.json`).then((x) => x.text()) + : await fetchModelsCatalog(`${modelsUrl}/api.json`) +assertUsableCatalog(modelsData, modelsOrigin) await Bun.write( path.join(dir, "src/provider/models-snapshot.ts"), `// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData.trim()} as const\n`, From 4d805aea9a0bdc33e0d646c5af353a51ec0a9a81 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 17:45:33 -0700 Subject: [PATCH 2/8] fix: harden the catalog guard against hangs and hollow payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the guard added in the previous commit. Bound the fetch with `AbortSignal.timeout(60s)`. A blackholed connection is the one failure `fetch` does not surface on its own — no error, no bytes, just a hang until the job's own timeout kills it with no useful message. The previous version claimed to fail loudly but would have hung in that case. Validate provider records, not just top-level keys. A payload can carry 50+ keys whose values are junk: that passed the old key-count check and would have shipped a catalog with nothing selectable. Each required provider must now carry a non-empty `models` object. Verified against a crafted payload of 80 junk keys and one where every provider is present but hollow. Also drops the duplicated `${modelsUrl}/api.json` construction and the repeated `process.env.MODELS_DEV_API_JSON` reads, and avoids an unsafe type assertion by reading the parsed catalog through a `Map`. Not changed: the eight matrix builds still fetch independently, so a models.dev update mid-release could in principle give different binaries different catalogs. Fixing that needs a fetch-once job passing the catalog as an artifact, which is a larger change than this PR; upstream has the same property. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/script/build.ts | 58 +++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 5b65e68d6c..f726cd0fb5 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -35,23 +35,38 @@ const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" // a shipped binary visibly broken. const MIN_CATALOG_PROVIDERS = 50 const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] +// A blackholed connection is the one failure `fetch` will not surface on its own: +// no error, no bytes, just a hang until the job's own timeout kills it with no +// useful message. Bound it so the build fails with a reason instead. +const CATALOG_FETCH_TIMEOUT_MS = 60_000 -/** Fetch the models.dev catalog, failing loudly on a non-2xx. +/** Fetch the models.dev catalog, failing loudly rather than hanging or + * returning an error page. * - * `fetch` resolves for 4xx/5xx, so without this check a load-balancer error page - * flows straight into the snapshot. An HTML body would at least break the build - * at parse time, but a JSON error body (`{"error": ...}`) is valid TypeScript and - * would ship as a catalog with no providers in it. */ + * `fetch` resolves for 4xx/5xx, so without the `res.ok` check a load-balancer + * error page flows straight into the snapshot. An HTML body would at least break + * the build at parse time, but a JSON error body (`{"error": ...}`) is valid + * TypeScript and would ship as a catalog with no providers in it. */ async function fetchModelsCatalog(url: string): Promise { - const res = await fetch(url) + let res: Response + try { + res = await fetch(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) }) + } catch (e) { + throw new Error(`models.dev fetch from ${url} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, { + cause: e, + }) + } if (!res.ok) throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${url}`) return await res.text() } /** Reject a catalog that parses but is obviously not usable. * - * Release builds embed this in every binary, so an empty or truncated payload - * has to stop the release rather than ship a CLI that offers no models. */ + * Release builds embed this in every binary, so an empty, truncated or + * structurally broken payload has to stop the release rather than ship a CLI + * that offers no models. Checking the top-level key count alone is not enough: + * a payload can carry 50+ keys whose values are junk, which parses fine and + * ships a catalog with nothing selectable in it. */ function assertUsableCatalog(text: string, origin: string): void { let parsed: unknown try { @@ -61,24 +76,37 @@ function assertUsableCatalog(text: string, origin: string): void { } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`models.dev catalog from ${origin} is not a provider object`) - const providers = Object.keys(parsed) + const catalog = new Map(Object.entries(parsed)) + const providers = [...catalog.keys()] if (providers.length < MIN_CATALOG_PROVIDERS) throw new Error( `models.dev catalog from ${origin} has only ${providers.length} providers, expected at least ${MIN_CATALOG_PROVIDERS}`, ) - const missing = REQUIRED_CATALOG_PROVIDERS.filter((p) => !providers.includes(p)) + const missing = REQUIRED_CATALOG_PROVIDERS.filter((p) => !catalog.has(p)) if (missing.length > 0) throw new Error(`models.dev catalog from ${origin} is missing required providers: ${missing.join(", ")}`) - console.log(`models.dev catalog from ${origin}: ${providers.length} providers`) + // Every required provider must actually carry models, not just exist as a key. + const modelCount = (id: string): number => { + const entry = catalog.get(id) + if (typeof entry !== "object" || entry === null || !("models" in entry)) return 0 + const models = entry.models + return typeof models === "object" && models !== null && !Array.isArray(models) ? Object.keys(models).length : 0 + } + const empty = REQUIRED_CATALOG_PROVIDERS.filter((p) => modelCount(p) === 0) + if (empty.length > 0) + throw new Error(`models.dev catalog from ${origin} has no usable models for: ${empty.join(", ")}`) + console.log( + `models.dev catalog from ${origin}: ${providers.length} providers ` + + `(${REQUIRED_CATALOG_PROVIDERS.map((p) => `${p}=${modelCount(p)}`).join(", ")})`, + ) } // Fetch and generate models.dev snapshot. MODELS_DEV_API_JSON pins the catalog to // a local file for hermetic builds (ci.yml, pre-release-check.ts); release builds // leave it unset so the shipped binary embeds a release-time catalog. -const modelsOrigin = process.env.MODELS_DEV_API_JSON ?? `${modelsUrl}/api.json` -const modelsData = process.env.MODELS_DEV_API_JSON - ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() - : await fetchModelsCatalog(`${modelsUrl}/api.json`) +const modelsFile = process.env.MODELS_DEV_API_JSON +const modelsOrigin = modelsFile ?? `${modelsUrl}/api.json` +const modelsData = modelsFile ? await Bun.file(modelsFile).text() : await fetchModelsCatalog(modelsOrigin) assertUsableCatalog(modelsData, modelsOrigin) await Bun.write( path.join(dir, "src/provider/models-snapshot.ts"), From eeea7c93118512031b80b24e473b59d7bb85d11a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 18:17:37 -0700 Subject: [PATCH 3/8] fix: repair the empty-env regression and harden the catalog guard further MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review wave on the guard. Three real defects, one of them mine. MINE — an empty `MODELS_DEV_API_JSON` broke the build. The previous commit replaced a consistent truthiness check with `??` when deduplicating the origin, so a variable that is SET BUT EMPTY kept `""` as `modelsOrigin` while the data branch fell through to the fetch. Reproduced: `error: models.dev fetch from failed ... code: "ERR_INVALID_URL"`. Now normalized with `|| undefined` so all three uses agree, and an empty value reads as unset again. The 60s bound did not cover a blocked DNS lookup. `AbortSignal.timeout` cannot cancel `getaddrinfo()` — this repo already documents that in src/provider/models.ts (#1052 D14), where a sandboxed-network blackhole outlived the signal. Added a `setTimeout` backstop that prints the origin and exits non-zero, so an unresolvable host fails the build with a reason instead of hanging every matrix job until the workflow timeout. `res.text()` sat outside the try, so a host that sends headers promptly then stalls mid-body surfaced a bare `AbortError` with none of the context. Body read now has its own catch and its own message. Custom catalogs were broken by an unconditional size floor. `OPENCODE_MODELS_URL` and `MODELS_DEV_API_JSON` are legitimate ways to point a build at a small private catalog, and those worked before this PR. The size floor and required-provider checks now run in `strict` mode only — ON for every release build (keyed on `OPENCODE_RELEASE`, so pointing a release at a custom catalog cannot skip them) and for any plain default-endpoint build; OFF only for a non-release build with an explicit override. Structural validation is now stronger and runs in BOTH modes: the catalog must be non-empty and every provider entry must carry a string `id` and an object `models`. This uses the same predicate the runtime screens entries with (`isCatalogEntry`), deliberately not the zod `Provider` schema, which requires an `options` record real models.dev entries lack. Verified across a 9-case matrix in both modes. Strict rejects all nine, including a hollow catalog and a 1-provider custom one; non-strict accepts the small custom catalog while still rejecting a single malformed entry hidden among 104 valid ones. Real fixture and live catalog both pass strict; all 105/144/207 providers in the fixture, committed blob and live catalog satisfy the structural check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/script/build.ts | 128 ++++++++++++++++++++++-------- 1 file changed, 95 insertions(+), 33 deletions(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index f726cd0fb5..8f1c4ea3e0 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -27,19 +27,33 @@ const changelogPath = path.resolve(dir, "../../CHANGELOG.md") const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).text() : "" console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) -const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" +const modelsUrlOverride = process.env.OPENCODE_MODELS_URL || undefined +const modelsUrl = modelsUrlOverride ?? "https://models.dev" -// A models.dev catalog small enough to trip this is a fetch that went wrong, not -// a real shrink: the live catalog carries 200+ providers and the checked-in test -// fixture carries 105. The named providers are the ones whose absence would make -// a shipped binary visibly broken. -const MIN_CATALOG_PROVIDERS = 50 +// The providers whose absence would make a shipped binary visibly broken. const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] -// A blackholed connection is the one failure `fetch` will not surface on its own: -// no error, no bytes, just a hang until the job's own timeout kills it with no -// useful message. Bound it so the build fails with a reason instead. +// Size floor for the real models.dev catalog: live carries 200+ providers and the +// checked-in fixture 105, so anything under this is a fetch that went wrong rather +// than a real shrink. Applied only in strict mode — a private catalog pinned via +// OPENCODE_MODELS_URL / MODELS_DEV_API_JSON is legitimately allowed to be small. +const MIN_CATALOG_PROVIDERS = 50 const CATALOG_FETCH_TIMEOUT_MS = 60_000 +/** True when `entry` is shaped like a models.dev provider entry. + * + * Deliberately the same structural check the runtime uses (`isCatalogEntry` in + * src/provider/models-catalog.ts, which `Provider.state()` screens every entry + * with), NOT the zod `Provider` schema: that schema requires an `options` record + * real models.dev entries do not carry, and gating on it rejects every provider in + * our own catalogs. See the note on `isCatalogEntry` for that history. */ +function isProviderEntry(entry: unknown): boolean { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false + if (!("id" in entry) || typeof entry.id !== "string") return false + if (!("models" in entry)) return false + const models = entry.models + return typeof models === "object" && models !== null && !Array.isArray(models) +} + /** Fetch the models.dev catalog, failing loudly rather than hanging or * returning an error page. * @@ -48,26 +62,57 @@ const CATALOG_FETCH_TIMEOUT_MS = 60_000 * the build at parse time, but a JSON error body (`{"error": ...}`) is valid * TypeScript and would ship as a catalog with no providers in it. */ async function fetchModelsCatalog(url: string): Promise { - let res: Response + // Hard backstop. `AbortSignal.timeout` cannot cancel a blocked `getaddrinfo()` + // — documented in src/provider/models.ts (#1052 D14), where a sandboxed-network + // DNS blackhole outlived the abort signal. Without this, an unresolvable host + // hangs every matrix build until the workflow job timeout and the useful message + // is lost. Exit non-zero instead; never fall through to a stale catalog. + const deadline = setTimeout(() => { + console.error( + `error: models.dev fetch from ${url} exceeded ${CATALOG_FETCH_TIMEOUT_MS}ms ` + + `(unresolvable host or blackholed network); failing the build`, + ) + process.exit(1) + }, CATALOG_FETCH_TIMEOUT_MS) try { - res = await fetch(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) }) - } catch (e) { - throw new Error(`models.dev fetch from ${url} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, { - cause: e, - }) + let res: Response + try { + res = await fetch(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) }) + } catch (e) { + throw new Error(`models.dev fetch from ${url} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, { + cause: e, + }) + } + if (!res.ok) throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${url}`) + try { + // Inside its own try: a host that sends headers promptly then stalls + // mid-body aborts here, and an uncaught abort surfaces as a bare + // AbortError carrying none of the context above. + return await res.text() + } catch (e) { + throw new Error(`models.dev body read from ${url} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, { + cause: e, + }) + } + } finally { + clearTimeout(deadline) } - if (!res.ok) throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${url}`) - return await res.text() } -/** Reject a catalog that parses but is obviously not usable. +/** Reject a catalog that parses but is not usable. * * Release builds embed this in every binary, so an empty, truncated or * structurally broken payload has to stop the release rather than ship a CLI - * that offers no models. Checking the top-level key count alone is not enough: - * a payload can carry 50+ keys whose values are junk, which parses fine and - * ships a catalog with nothing selectable in it. */ -function assertUsableCatalog(text: string, origin: string): void { + * that offers no models. + * + * `strict` adds the checks that only make sense for the real models.dev catalog: + * the size floor, and the requirement that the major providers carry models rather + * than merely appear as keys. It is ON for every release build and for any plain + * default-endpoint build, and OFF only when an operator has deliberately pointed + * the build at a custom catalog, which is allowed to be small. The structural + * checks run in both modes — a key count alone is not enough, since a payload can + * carry 50+ keys whose values are junk and still parse. */ +function assertUsableCatalog(text: string, origin: string, strict: boolean): void { let parsed: unknown try { parsed = JSON.parse(text) @@ -77,26 +122,36 @@ function assertUsableCatalog(text: string, origin: string): void { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`models.dev catalog from ${origin} is not a provider object`) const catalog = new Map(Object.entries(parsed)) - const providers = [...catalog.keys()] - if (providers.length < MIN_CATALOG_PROVIDERS) + if (catalog.size === 0) throw new Error(`models.dev catalog from ${origin} is empty`) + const malformed = [...catalog.entries()].filter(([, entry]) => !isProviderEntry(entry)).map(([id]) => id) + if (malformed.length > 0) throw new Error( - `models.dev catalog from ${origin} has only ${providers.length} providers, expected at least ${MIN_CATALOG_PROVIDERS}`, + `models.dev catalog from ${origin} has ${malformed.length} malformed provider entries: ` + + `${malformed.slice(0, 5).join(", ")}${malformed.length > 5 ? ", …" : ""}`, ) - const missing = REQUIRED_CATALOG_PROVIDERS.filter((p) => !catalog.has(p)) - if (missing.length > 0) - throw new Error(`models.dev catalog from ${origin} is missing required providers: ${missing.join(", ")}`) - // Every required provider must actually carry models, not just exist as a key. const modelCount = (id: string): number => { const entry = catalog.get(id) if (typeof entry !== "object" || entry === null || !("models" in entry)) return 0 const models = entry.models - return typeof models === "object" && models !== null && !Array.isArray(models) ? Object.keys(models).length : 0 + return typeof models === "object" && models !== null ? Object.keys(models).length : 0 + } + if (!strict) { + console.log(`models.dev catalog from ${origin}: ${catalog.size} providers (custom catalog, size floor not applied)`) + return } + if (catalog.size < MIN_CATALOG_PROVIDERS) + throw new Error( + `models.dev catalog from ${origin} has only ${catalog.size} providers, expected at least ${MIN_CATALOG_PROVIDERS}`, + ) + const missing = REQUIRED_CATALOG_PROVIDERS.filter((p) => !catalog.has(p)) + if (missing.length > 0) + throw new Error(`models.dev catalog from ${origin} is missing required providers: ${missing.join(", ")}`) + // Every required provider must actually carry models, not just exist as a key. const empty = REQUIRED_CATALOG_PROVIDERS.filter((p) => modelCount(p) === 0) if (empty.length > 0) throw new Error(`models.dev catalog from ${origin} has no usable models for: ${empty.join(", ")}`) console.log( - `models.dev catalog from ${origin}: ${providers.length} providers ` + + `models.dev catalog from ${origin}: ${catalog.size} providers ` + `(${REQUIRED_CATALOG_PROVIDERS.map((p) => `${p}=${modelCount(p)}`).join(", ")})`, ) } @@ -104,10 +159,17 @@ function assertUsableCatalog(text: string, origin: string): void { // Fetch and generate models.dev snapshot. MODELS_DEV_API_JSON pins the catalog to // a local file for hermetic builds (ci.yml, pre-release-check.ts); release builds // leave it unset so the shipped binary embeds a release-time catalog. -const modelsFile = process.env.MODELS_DEV_API_JSON +// `|| undefined` rather than `??`: an env var that is SET BUT EMPTY has to read as +// unset, or the origin keeps "" while the data branch falls through to the fetch +// and the build dies on `fetch("")` with ERR_INVALID_URL. +const modelsFile = process.env.MODELS_DEV_API_JSON || undefined const modelsOrigin = modelsFile ?? `${modelsUrl}/api.json` const modelsData = modelsFile ? await Bun.file(modelsFile).text() : await fetchModelsCatalog(modelsOrigin) -assertUsableCatalog(modelsData, modelsOrigin) +// A release is held to the full floor however its catalog was sourced, so pointing +// a release build at a custom catalog cannot quietly skip the size and +// required-provider checks. +const strictCatalog = !!process.env.OPENCODE_RELEASE || (!modelsFile && !modelsUrlOverride) +assertUsableCatalog(modelsData, modelsOrigin, strictCatalog) await Bun.write( path.join(dir, "src/provider/models-snapshot.ts"), `// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData.trim()} as const\n`, From 904aa5bd4b7f1a5614ad67bdccc8acef71c78a2e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 18:30:05 -0700 Subject: [PATCH 4/8] fix: validate nested model records before embedding the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found a real unguarded dereference. `Provider.fromModelsDevModel` reads `model.limit.context` directly while every neighbouring field uses `?.`/`??`: limit: { context: model.limit.context, and the runtime's per-entry screen (`isCatalogEntry`) only checks that a provider has a string `id` and an object `models` — it does not look inside the map. So a catalog carrying a null or `limit`-less model record passes every existing check and crashes provider initialisation on cold start. Provider validation now walks each provider's models and requires every value to be an object with a `limit` object whose `context` is a number — exactly the fields dereferenced without a guard, and nothing more. Errors name the offending model (`openai/gpt-5.2-codex (no limit)`) rather than just the provider. Still deliberately not the zod `Provider` schema, which requires an `options` record real models.dev entries lack and would reject valid live data — the history is recorded on `isCatalogEntry` in src/provider/models-catalog.ts. Checked for false positives before adding it: every model in the release fixture (4108), the committed snapshot (5299) and the live catalog (7487) passes, so zero real records are rejected. Runs in both strict and relaxed modes, since a custom catalog with a malformed model record would crash the runtime just the same. Verified against a fixture with `limit` deleted from one model and another with a model value set to null; both are rejected in both modes, and the real fixture still passes both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/script/build.ts | 48 ++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 8f1c4ea3e0..db7d982f09 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -39,19 +39,37 @@ const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] const MIN_CATALOG_PROVIDERS = 50 const CATALOG_FETCH_TIMEOUT_MS = 60_000 -/** True when `entry` is shaped like a models.dev provider entry. +/** Describe the first structural problem in a provider entry, or undefined when + * it is well formed. * - * Deliberately the same structural check the runtime uses (`isCatalogEntry` in + * The provider-level rules match the runtime's own predicate (`isCatalogEntry` in * src/provider/models-catalog.ts, which `Provider.state()` screens every entry - * with), NOT the zod `Provider` schema: that schema requires an `options` record - * real models.dev entries do not carry, and gating on it rejects every provider in - * our own catalogs. See the note on `isCatalogEntry` for that history. */ -function isProviderEntry(entry: unknown): boolean { - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false - if (!("id" in entry) || typeof entry.id !== "string") return false - if (!("models" in entry)) return false + * with). The per-model rules cover exactly the fields the runtime dereferences + * WITHOUT a guard: `Provider.fromModelsDevModel` reads `model.limit.context` + * directly while every neighbouring field uses `?.`/`??`, so a model record + * missing `limit` crashes provider initialisation on cold start instead of + * degrading. Catching it here turns that into a failed release build. + * + * Deliberately NOT the zod `Provider` schema, which requires an `options` record + * real models.dev entries do not carry and would reject valid live data — see the + * note on `isCatalogEntry` for that history. Verified against every model in the + * release fixture (4108), the committed snapshot (5299) and the live catalog + * (7487): zero failures. */ +function providerEntryProblem(id: string, entry: unknown): string | undefined { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return `${id} (not an object)` + if (!("id" in entry) || typeof entry.id !== "string") return `${id} (no string id)` + if (!("models" in entry)) return `${id} (no models)` const models = entry.models - return typeof models === "object" && models !== null && !Array.isArray(models) + if (typeof models !== "object" || models === null || Array.isArray(models)) return `${id} (models is not a map)` + for (const [modelId, model] of Object.entries(models)) { + const where = `${id}/${modelId}` + if (typeof model !== "object" || model === null || Array.isArray(model)) return `${where} (not an object)` + if (!("limit" in model)) return `${where} (no limit)` + const limit = model.limit + if (typeof limit !== "object" || limit === null || Array.isArray(limit)) return `${where} (limit is not an object)` + if (!("context" in limit) || typeof limit.context !== "number") return `${where} (limit.context is not a number)` + } + return undefined } /** Fetch the models.dev catalog, failing loudly rather than hanging or @@ -123,11 +141,13 @@ function assertUsableCatalog(text: string, origin: string, strict: boolean): voi throw new Error(`models.dev catalog from ${origin} is not a provider object`) const catalog = new Map(Object.entries(parsed)) if (catalog.size === 0) throw new Error(`models.dev catalog from ${origin} is empty`) - const malformed = [...catalog.entries()].filter(([, entry]) => !isProviderEntry(entry)).map(([id]) => id) - if (malformed.length > 0) + const problems = [...catalog.entries()] + .map(([id, entry]) => providerEntryProblem(id, entry)) + .filter((p): p is string => p !== undefined) + if (problems.length > 0) throw new Error( - `models.dev catalog from ${origin} has ${malformed.length} malformed provider entries: ` + - `${malformed.slice(0, 5).join(", ")}${malformed.length > 5 ? ", …" : ""}`, + `models.dev catalog from ${origin} has ${problems.length} malformed provider entries: ` + + `${problems.slice(0, 5).join(", ")}${problems.length > 5 ? ", …" : ""}`, ) const modelCount = (id: string): number => { const entry = catalog.get(id) From 2f271de4192c62b4229584f3e4e2ce35770f53bc Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 18:34:19 -0700 Subject: [PATCH 5/8] fix: give the fetch backstop a margin and stop overclaiming what it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the backstop added in eeea7c9311, both correct. The backstop shared CATALOG_FETCH_TIMEOUT_MS with the abort signal and was armed before the fetch, so at the timeout boundary `process.exit(1)` always won the race and replaced the precise per-stage messages ("fetch failed", "body read failed") with its own generic one. A slow-but-reachable catalog or a mid-body stall would have been reported as an unresolvable host. The backstop now runs at CATALOG_FETCH_TIMEOUT_MS + 15s, so the abort path fires first wherever the abort signal can act at all, and the backstop is reserved for the case it exists for. The comment also overclaimed. A `setTimeout` callback runs on the event loop, so if `getaddrinfo` blocks that loop outright the timer cannot fire any more than the abort signal can — the claim that this bounds the DNS-blackhole case was wrong. The comment now states the real coverage: it catches a request that hangs unresolved while the loop still ticks, turning a silent full-length job timeout into a fast labelled failure, and the workflow `timeout-minutes` remains the backstop for a genuinely blocked thread. Either way the build fails and never falls through to a stale catalog. Message reworded from "exceeded Nms (unresolvable host or blackholed network)" to "did not settle within Nms (host unreachable or unresolvable)", which is what is actually known at that point. Verified the ordering: a refused connection still reports "models.dev fetch from ... failed or timed out after 60000ms", not the backstop message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/script/build.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index db7d982f09..4efd3f8b24 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -38,6 +38,11 @@ const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] // OPENCODE_MODELS_URL / MODELS_DEV_API_JSON is legitimately allowed to be small. const MIN_CATALOG_PROVIDERS = 50 const CATALOG_FETCH_TIMEOUT_MS = 60_000 +// The hard backstop must lose the race to `AbortSignal.timeout` in every case the +// signal CAN handle, or it fires first and replaces the precise per-stage message +// ("fetch failed", "body read failed") with its own generic one. The margin is +// what makes it a backstop rather than the primary timeout. +const CATALOG_HARD_DEADLINE_MS = CATALOG_FETCH_TIMEOUT_MS + 15_000 /** Describe the first structural problem in a provider entry, or undefined when * it is well formed. @@ -80,18 +85,25 @@ function providerEntryProblem(id: string, entry: unknown): string | undefined { * the build at parse time, but a JSON error body (`{"error": ...}`) is valid * TypeScript and would ship as a catalog with no providers in it. */ async function fetchModelsCatalog(url: string): Promise { - // Hard backstop. `AbortSignal.timeout` cannot cancel a blocked `getaddrinfo()` - // — documented in src/provider/models.ts (#1052 D14), where a sandboxed-network - // DNS blackhole outlived the abort signal. Without this, an unresolvable host - // hangs every matrix build until the workflow job timeout and the useful message - // is lost. Exit non-zero instead; never fall through to a stale catalog. + // Backstop for the case where the abort signal fires but the fetch promise never + // settles, so the `catch` below is never reached. `AbortSignal.timeout` cannot + // cancel a blocked `getaddrinfo()` — documented in src/provider/models.ts + // (#1052 D14), where a sandboxed-network DNS blackhole outlived the signal. + // + // HONEST LIMIT: this is a timer on the event loop, so it cannot preempt a + // genuinely blocked main thread either. If `getaddrinfo` blocks the loop + // outright, neither the signal nor this fires and the workflow `timeout-minutes` + // stays the real backstop. What this does cover is the more common shape — the + // loop still ticking while a request hangs unresolved — turning a silent + // full-length job timeout into a fast, labelled failure. Either way the build + // fails; it never falls through to a stale catalog. const deadline = setTimeout(() => { console.error( - `error: models.dev fetch from ${url} exceeded ${CATALOG_FETCH_TIMEOUT_MS}ms ` + - `(unresolvable host or blackholed network); failing the build`, + `error: models.dev fetch from ${url} did not settle within ${CATALOG_HARD_DEADLINE_MS}ms ` + + `(host unreachable or unresolvable); failing the build`, ) process.exit(1) - }, CATALOG_FETCH_TIMEOUT_MS) + }, CATALOG_HARD_DEADLINE_MS) try { let res: Response try { From d6dbcddce4353de3c76b6ad055224265ab01ecce Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 19:22:05 -0700 Subject: [PATCH 6/8] fix: validate release model catalog safely --- packages/opencode/script/build.ts | 131 ++-------- packages/opencode/script/models-catalog.ts | 148 +++++++++++ .../provider/build-models-catalog.test.ts | 236 ++++++++++++++++++ 3 files changed, 407 insertions(+), 108 deletions(-) create mode 100644 packages/opencode/script/models-catalog.ts create mode 100644 packages/opencode/test/provider/build-models-catalog.test.ts diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 4efd3f8b24..0c759d22f4 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -18,6 +18,7 @@ process.chdir(dir) import { Script } from "@opencode-ai/script" import pkg from "../package.json" import { walkInputs } from "./stamp-inputs" +import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "./models-catalog" // Python engine has been eliminated — all methods run natively in TypeScript. // ALTIMATE_ENGINE_VERSION is no longer needed at runtime. @@ -30,13 +31,6 @@ console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) const modelsUrlOverride = process.env.OPENCODE_MODELS_URL || undefined const modelsUrl = modelsUrlOverride ?? "https://models.dev" -// The providers whose absence would make a shipped binary visibly broken. -const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] -// Size floor for the real models.dev catalog: live carries 200+ providers and the -// checked-in fixture 105, so anything under this is a fetch that went wrong rather -// than a real shrink. Applied only in strict mode — a private catalog pinned via -// OPENCODE_MODELS_URL / MODELS_DEV_API_JSON is legitimately allowed to be small. -const MIN_CATALOG_PROVIDERS = 50 const CATALOG_FETCH_TIMEOUT_MS = 60_000 // The hard backstop must lose the race to `AbortSignal.timeout` in every case the // signal CAN handle, or it fires first and replaces the precise per-stage message @@ -44,39 +38,6 @@ const CATALOG_FETCH_TIMEOUT_MS = 60_000 // what makes it a backstop rather than the primary timeout. const CATALOG_HARD_DEADLINE_MS = CATALOG_FETCH_TIMEOUT_MS + 15_000 -/** Describe the first structural problem in a provider entry, or undefined when - * it is well formed. - * - * The provider-level rules match the runtime's own predicate (`isCatalogEntry` in - * src/provider/models-catalog.ts, which `Provider.state()` screens every entry - * with). The per-model rules cover exactly the fields the runtime dereferences - * WITHOUT a guard: `Provider.fromModelsDevModel` reads `model.limit.context` - * directly while every neighbouring field uses `?.`/`??`, so a model record - * missing `limit` crashes provider initialisation on cold start instead of - * degrading. Catching it here turns that into a failed release build. - * - * Deliberately NOT the zod `Provider` schema, which requires an `options` record - * real models.dev entries do not carry and would reject valid live data — see the - * note on `isCatalogEntry` for that history. Verified against every model in the - * release fixture (4108), the committed snapshot (5299) and the live catalog - * (7487): zero failures. */ -function providerEntryProblem(id: string, entry: unknown): string | undefined { - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return `${id} (not an object)` - if (!("id" in entry) || typeof entry.id !== "string") return `${id} (no string id)` - if (!("models" in entry)) return `${id} (no models)` - const models = entry.models - if (typeof models !== "object" || models === null || Array.isArray(models)) return `${id} (models is not a map)` - for (const [modelId, model] of Object.entries(models)) { - const where = `${id}/${modelId}` - if (typeof model !== "object" || model === null || Array.isArray(model)) return `${where} (not an object)` - if (!("limit" in model)) return `${where} (no limit)` - const limit = model.limit - if (typeof limit !== "object" || limit === null || Array.isArray(limit)) return `${where} (limit is not an object)` - if (!("context" in limit) || typeof limit.context !== "number") return `${where} (limit.context is not a number)` - } - return undefined -} - /** Fetch the models.dev catalog, failing loudly rather than hanging or * returning an error page. * @@ -84,7 +45,7 @@ function providerEntryProblem(id: string, entry: unknown): string | undefined { * error page flows straight into the snapshot. An HTML body would at least break * the build at parse time, but a JSON error body (`{"error": ...}`) is valid * TypeScript and would ship as a catalog with no providers in it. */ -async function fetchModelsCatalog(url: string): Promise { +async function fetchModelsCatalog(url: string, diagnosticOrigin: string): Promise { // Backstop for the case where the abort signal fires but the fetch promise never // settles, so the `catch` below is never reached. `AbortSignal.timeout` cannot // cancel a blocked `getaddrinfo()` — documented in src/provider/models.ts @@ -99,7 +60,7 @@ async function fetchModelsCatalog(url: string): Promise { // fails; it never falls through to a stale catalog. const deadline = setTimeout(() => { console.error( - `error: models.dev fetch from ${url} did not settle within ${CATALOG_HARD_DEADLINE_MS}ms ` + + `error: models.dev fetch from ${diagnosticOrigin} did not settle within ${CATALOG_HARD_DEADLINE_MS}ms ` + `(host unreachable or unresolvable); failing the build`, ) process.exit(1) @@ -108,84 +69,34 @@ async function fetchModelsCatalog(url: string): Promise { let res: Response try { res = await fetch(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) }) - } catch (e) { - throw new Error(`models.dev fetch from ${url} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, { - cause: e, - }) + } catch { + throw new Error( + `models.dev fetch from ${diagnosticOrigin} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, + ) } - if (!res.ok) throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${url}`) + if (!res.ok) + throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${diagnosticOrigin}`) try { // Inside its own try: a host that sends headers promptly then stalls // mid-body aborts here, and an uncaught abort surfaces as a bare // AbortError carrying none of the context above. return await res.text() - } catch (e) { - throw new Error(`models.dev body read from ${url} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, { - cause: e, - }) + } catch { + throw new Error( + `models.dev body read from ${diagnosticOrigin} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`, + ) } } finally { clearTimeout(deadline) } } -/** Reject a catalog that parses but is not usable. - * - * Release builds embed this in every binary, so an empty, truncated or - * structurally broken payload has to stop the release rather than ship a CLI - * that offers no models. - * - * `strict` adds the checks that only make sense for the real models.dev catalog: - * the size floor, and the requirement that the major providers carry models rather - * than merely appear as keys. It is ON for every release build and for any plain - * default-endpoint build, and OFF only when an operator has deliberately pointed - * the build at a custom catalog, which is allowed to be small. The structural - * checks run in both modes — a key count alone is not enough, since a payload can - * carry 50+ keys whose values are junk and still parse. */ -function assertUsableCatalog(text: string, origin: string, strict: boolean): void { - let parsed: unknown +async function readModelsCatalog(file: string, diagnosticOrigin: string): Promise { try { - parsed = JSON.parse(text) - } catch (e) { - throw new Error(`models.dev catalog from ${origin} is not valid JSON`, { cause: e }) - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) - throw new Error(`models.dev catalog from ${origin} is not a provider object`) - const catalog = new Map(Object.entries(parsed)) - if (catalog.size === 0) throw new Error(`models.dev catalog from ${origin} is empty`) - const problems = [...catalog.entries()] - .map(([id, entry]) => providerEntryProblem(id, entry)) - .filter((p): p is string => p !== undefined) - if (problems.length > 0) - throw new Error( - `models.dev catalog from ${origin} has ${problems.length} malformed provider entries: ` + - `${problems.slice(0, 5).join(", ")}${problems.length > 5 ? ", …" : ""}`, - ) - const modelCount = (id: string): number => { - const entry = catalog.get(id) - if (typeof entry !== "object" || entry === null || !("models" in entry)) return 0 - const models = entry.models - return typeof models === "object" && models !== null ? Object.keys(models).length : 0 - } - if (!strict) { - console.log(`models.dev catalog from ${origin}: ${catalog.size} providers (custom catalog, size floor not applied)`) - return + return await Bun.file(file).text() + } catch { + throw new Error(`models.dev catalog read from ${diagnosticOrigin} failed`) } - if (catalog.size < MIN_CATALOG_PROVIDERS) - throw new Error( - `models.dev catalog from ${origin} has only ${catalog.size} providers, expected at least ${MIN_CATALOG_PROVIDERS}`, - ) - const missing = REQUIRED_CATALOG_PROVIDERS.filter((p) => !catalog.has(p)) - if (missing.length > 0) - throw new Error(`models.dev catalog from ${origin} is missing required providers: ${missing.join(", ")}`) - // Every required provider must actually carry models, not just exist as a key. - const empty = REQUIRED_CATALOG_PROVIDERS.filter((p) => modelCount(p) === 0) - if (empty.length > 0) - throw new Error(`models.dev catalog from ${origin} has no usable models for: ${empty.join(", ")}`) - console.log( - `models.dev catalog from ${origin}: ${catalog.size} providers ` + - `(${REQUIRED_CATALOG_PROVIDERS.map((p) => `${p}=${modelCount(p)}`).join(", ")})`, - ) } // Fetch and generate models.dev snapshot. MODELS_DEV_API_JSON pins the catalog to @@ -196,12 +107,16 @@ function assertUsableCatalog(text: string, origin: string, strict: boolean): voi // and the build dies on `fetch("")` with ERR_INVALID_URL. const modelsFile = process.env.MODELS_DEV_API_JSON || undefined const modelsOrigin = modelsFile ?? `${modelsUrl}/api.json` -const modelsData = modelsFile ? await Bun.file(modelsFile).text() : await fetchModelsCatalog(modelsOrigin) +const modelsDiagnosticOrigin = catalogDiagnosticOrigin(modelsOrigin, modelsFile ? "file" : "url") +const modelsData = modelsFile + ? await readModelsCatalog(modelsFile, modelsDiagnosticOrigin) + : await fetchModelsCatalog(modelsOrigin, modelsDiagnosticOrigin) // A release is held to the full floor however its catalog was sourced, so pointing // a release build at a custom catalog cannot quietly skip the size and // required-provider checks. const strictCatalog = !!process.env.OPENCODE_RELEASE || (!modelsFile && !modelsUrlOverride) -assertUsableCatalog(modelsData, modelsOrigin, strictCatalog) +const catalogSummary = assertUsableCatalog(modelsData, modelsDiagnosticOrigin, strictCatalog) +console.log(formatCatalogSummary(catalogSummary, modelsDiagnosticOrigin)) await Bun.write( path.join(dir, "src/provider/models-snapshot.ts"), `// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData.trim()} as const\n`, diff --git a/packages/opencode/script/models-catalog.ts b/packages/opencode/script/models-catalog.ts new file mode 100644 index 0000000000..c30c09f1f6 --- /dev/null +++ b/packages/opencode/script/models-catalog.ts @@ -0,0 +1,148 @@ +export const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] as const +export const MIN_CATALOG_PROVIDERS = 50 + +type CatalogSummary = { + providerCount: number + requiredModelCounts: Record + strict: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function optionalStringProblem(value: unknown, where: string, allowEmpty = true): string | undefined { + if (value === undefined || value === null) return undefined + if (typeof value !== "string") return `${where} is not a string` + if (!allowEmpty && value.length === 0) return `${where} is empty` + return undefined +} + +function stringArrayProblem(value: unknown, where: string): string | undefined { + if (value === undefined || value === null) return undefined + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${where} is not a string array` + return undefined +} + +/** Describe the first runtime-relevant structural problem in a provider entry. */ +function providerEntryProblem(id: string, entry: unknown): string | undefined { + if (!isRecord(entry)) return `${id} (not an object)` + if (typeof entry.id !== "string" || entry.id.length === 0) return `${id} (no non-empty string id)` + if (entry.id !== id) return `${id} (id does not match catalog key)` + + const envProblem = stringArrayProblem(entry.env, `${id}.env`) + if (envProblem) return envProblem + const apiProblem = optionalStringProblem(entry.api, `${id}.api`) + if (apiProblem) return apiProblem + const npmProblem = optionalStringProblem(entry.npm, `${id}.npm`, false) + if (npmProblem) return npmProblem + + if (!("models" in entry)) return `${id} (no models)` + if (!isRecord(entry.models)) return `${id} (models is not a map)` + + for (const [modelId, model] of Object.entries(entry.models)) { + const where = `${id}/${modelId}` + if (!isRecord(model)) return `${where} (not an object)` + if (typeof model.id !== "string" || model.id.length === 0) return `${where} (no non-empty string id)` + if (model.id !== modelId) return `${where} (id does not match catalog key)` + + if (model.provider !== undefined && model.provider !== null) { + if (!isRecord(model.provider)) return `${where}.provider is not an object` + const modelApiProblem = optionalStringProblem(model.provider.api, `${where}.provider.api`) + if (modelApiProblem) return modelApiProblem + const modelNpmProblem = optionalStringProblem(model.provider.npm, `${where}.provider.npm`, false) + if (modelNpmProblem) return modelNpmProblem + } + + if (!isRecord(model.limit)) return `${where} (limit is not an object)` + if (typeof model.limit.context !== "number") return `${where} (limit.context is not a number)` + if (typeof model.limit.output !== "number") return `${where} (limit.output is not a number)` + if (model.limit.input !== undefined && model.limit.input !== null && typeof model.limit.input !== "number") + return `${where} (limit.input is not a number)` + + if (model.modalities !== undefined && model.modalities !== null) { + if (!isRecord(model.modalities)) return `${where}.modalities is not an object` + const inputProblem = stringArrayProblem(model.modalities.input, `${where}.modalities.input`) + if (inputProblem) return inputProblem + const outputProblem = stringArrayProblem(model.modalities.output, `${where}.modalities.output`) + if (outputProblem) return outputProblem + } + } + return undefined +} + +/** Return a log-safe description without URL userinfo, path, query or fragment. */ +export function catalogDiagnosticOrigin(source: string, kind: "file" | "url"): string { + if (kind === "file") return "local catalog file" + try { + const url = new URL(source) + if (url.protocol !== "http:" && url.protocol !== "https:") return "custom catalog endpoint" + return url.origin + } catch { + return "custom catalog endpoint" + } +} + +/** Reject a catalog that parses but cannot be consumed safely at runtime. */ +export function assertUsableCatalog(text: string, diagnosticOrigin: string, strict: boolean): CatalogSummary { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error(`models.dev catalog from ${diagnosticOrigin} is not valid JSON`) + } + if (!isRecord(parsed)) throw new Error(`models.dev catalog from ${diagnosticOrigin} is not a provider object`) + + const catalog = new Map(Object.entries(parsed)) + if (catalog.size === 0) throw new Error(`models.dev catalog from ${diagnosticOrigin} is empty`) + + const problems = [...catalog.entries()] + .map(([id, entry]) => providerEntryProblem(id, entry)) + .filter((problem): problem is string => problem !== undefined) + if (problems.length > 0) + throw new Error( + `models.dev catalog from ${diagnosticOrigin} has ${problems.length} malformed provider entries: ` + + `${problems.slice(0, 5).join(", ")}${problems.length > 5 ? ", …" : ""}`, + ) + + const modelCount = (id: string): number => { + const entry = catalog.get(id) + if (!isRecord(entry) || !isRecord(entry.models)) return 0 + return Object.keys(entry.models).length + } + + if (strict) { + if (catalog.size < MIN_CATALOG_PROVIDERS) + throw new Error( + `models.dev catalog from ${diagnosticOrigin} has only ${catalog.size} providers, ` + + `expected at least ${MIN_CATALOG_PROVIDERS}`, + ) + const missing = REQUIRED_CATALOG_PROVIDERS.filter((provider) => !catalog.has(provider)) + if (missing.length > 0) + throw new Error( + `models.dev catalog from ${diagnosticOrigin} is missing required providers: ${missing.join(", ")}`, + ) + const empty = REQUIRED_CATALOG_PROVIDERS.filter((provider) => modelCount(provider) === 0) + if (empty.length > 0) + throw new Error(`models.dev catalog from ${diagnosticOrigin} has no usable models for: ${empty.join(", ")}`) + } + + return { + providerCount: catalog.size, + requiredModelCounts: Object.fromEntries( + REQUIRED_CATALOG_PROVIDERS.map((provider) => [provider, modelCount(provider)]), + ), + strict, + } +} + +export function formatCatalogSummary(summary: CatalogSummary, diagnosticOrigin: string): string { + if (!summary.strict) + return `models.dev catalog from ${diagnosticOrigin}: ${summary.providerCount} providers (custom catalog, size floor not applied)` + return ( + `models.dev catalog from ${diagnosticOrigin}: ${summary.providerCount} providers ` + + `(${REQUIRED_CATALOG_PROVIDERS.map((provider) => `${provider}=${summary.requiredModelCounts[provider]}`).join( + ", ", + )})` + ) +} diff --git a/packages/opencode/test/provider/build-models-catalog.test.ts b/packages/opencode/test/provider/build-models-catalog.test.ts new file mode 100644 index 0000000000..9fd17a60ab --- /dev/null +++ b/packages/opencode/test/provider/build-models-catalog.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "../../script/models-catalog" + +const diagnosticOrigin = "https://catalog.example.com" + +type MutableCatalog = Record< + string, + { + id: unknown + env?: unknown + api?: unknown + npm?: unknown + models: Record< + string, + { + id: unknown + provider?: unknown + limit: Record + modalities: Record + } + > + } +> + +function customCatalog(): MutableCatalog { + return { + acme: { + id: "acme", + env: ["ACME_API_KEY"], + api: "https://api.example.com/v1", + npm: "@ai-sdk/openai-compatible", + models: { + "acme-one": { + id: "acme-one", + limit: { context: 128_000, input: 120_000, output: 8_000 }, + modalities: { input: ["text"], output: ["text"] }, + }, + }, + }, + } +} + +function strictCatalog(): MutableCatalog { + return Object.fromEntries( + ["anthropic", "openai", "google", ...Array.from({ length: 47 }, (_, index) => `provider-${index}`)].map( + (provider) => { + const model = `${provider}-model` + return [ + provider, + { + id: provider, + models: { + [model]: { + id: model, + limit: { context: 128_000, output: 8_000 }, + modalities: {}, + }, + }, + }, + ] + }, + ), + ) +} + +describe("build models catalog validation", () => { + test("accepts a small custom catalog without applying the release floor", () => { + const summary = assertUsableCatalog(JSON.stringify(customCatalog()), diagnosticOrigin, false) + + expect(summary).toEqual({ + providerCount: 1, + requiredModelCounts: { anthropic: 0, openai: 0, google: 0 }, + strict: false, + }) + expect(formatCatalogSummary(summary, diagnosticOrigin)).toBe( + "models.dev catalog from https://catalog.example.com: 1 providers (custom catalog, size floor not applied)", + ) + }) + + test("accepts the release fixture in strict mode", async () => { + const fixture = await Bun.file(new URL("../tool/fixtures/models-api.json", import.meta.url)).text() + const summary = assertUsableCatalog(fixture, "release fixture", true) + + expect(summary.providerCount).toBeGreaterThanOrEqual(50) + expect(summary.requiredModelCounts.anthropic).toBeGreaterThan(0) + expect(summary.requiredModelCounts.openai).toBeGreaterThan(0) + expect(summary.requiredModelCounts.google).toBeGreaterThan(0) + }) + + for (const [label, body, message] of [ + ["non-JSON", "bad gateway", "is not valid JSON"], + ["an array", "[]", "is not a provider object"], + ["an empty object", "{}", "is empty"], + ["an object-shaped error", '{"error":"bad gateway"}', "error (not an object)"], + ] as const) { + test(`rejects ${label}`, () => { + expect(() => assertUsableCatalog(body, diagnosticOrigin, false)).toThrow(message) + }) + } + + const malformedCases: ReadonlyArray<{ + label: string + mutate: (catalog: MutableCatalog) => void + message: string + }> = [ + { + label: "a provider ID that differs from its catalog key", + mutate: (catalog) => (catalog.acme.id = "other"), + message: "acme (id does not match catalog key)", + }, + { + label: "a model without a string ID", + mutate: (catalog) => (catalog.acme.models["acme-one"].id = 42), + message: "acme/acme-one (no non-empty string id)", + }, + { + label: "a model ID that differs from its catalog key", + mutate: (catalog) => (catalog.acme.models["acme-one"].id = "other"), + message: "acme/acme-one (id does not match catalog key)", + }, + { + label: "a non-array provider env", + mutate: (catalog) => (catalog.acme.env = "ACME_API_KEY"), + message: "acme.env is not a string array", + }, + { + label: "a provider env containing a non-string", + mutate: (catalog) => (catalog.acme.env = [42]), + message: "acme.env is not a string array", + }, + { + label: "a non-string provider API URL", + mutate: (catalog) => (catalog.acme.api = 42), + message: "acme.api is not a string", + }, + { + label: "an empty provider npm package", + mutate: (catalog) => (catalog.acme.npm = ""), + message: "acme.npm is empty", + }, + { + label: "a non-string model provider API URL", + mutate: (catalog) => (catalog.acme.models["acme-one"].provider = { api: 42 }), + message: "acme/acme-one.provider.api is not a string", + }, + { + label: "a model without an output limit", + mutate: (catalog) => delete catalog.acme.models["acme-one"].limit.output, + message: "acme/acme-one (limit.output is not a number)", + }, + { + label: "a non-numeric optional input limit", + mutate: (catalog) => (catalog.acme.models["acme-one"].limit.input = "many"), + message: "acme/acme-one (limit.input is not a number)", + }, + { + label: "non-array model input modalities", + mutate: (catalog) => (catalog.acme.models["acme-one"].modalities.input = {}), + message: "acme/acme-one.modalities.input is not a string array", + }, + { + label: "model output modalities containing a non-string", + mutate: (catalog) => (catalog.acme.models["acme-one"].modalities.output = [42]), + message: "acme/acme-one.modalities.output is not a string array", + }, + ] + + for (const { label, mutate, message } of malformedCases) { + test(`rejects ${label} in every mode`, () => { + const catalog = customCatalog() + mutate(catalog) + const body = JSON.stringify(catalog) + + expect(() => assertUsableCatalog(body, diagnosticOrigin, false)).toThrow(message) + expect(() => assertUsableCatalog(body, diagnosticOrigin, true)).toThrow(message) + }) + } + + test("applies the provider floor only in strict mode", () => { + const body = JSON.stringify(customCatalog()) + expect(() => assertUsableCatalog(body, diagnosticOrigin, true)).toThrow("has only 1 providers") + expect(() => assertUsableCatalog(body, diagnosticOrigin, false)).not.toThrow() + }) + + test("requires every major provider to carry models in strict mode", () => { + const catalog = strictCatalog() + catalog.openai.models = {} + + expect(() => assertUsableCatalog(JSON.stringify(catalog), "release fixture", true)).toThrow( + "has no usable models for: openai", + ) + }) +}) + +describe("build models catalog diagnostics", () => { + test("keeps only the URL origin", () => { + const source = + "https://catalog-user:catalog-password@catalog.example.com:8443/private/token/api.json?key=query-secret#hash-secret" + const diagnostic = catalogDiagnosticOrigin(source, "url") + + expect(diagnostic).toBe("https://catalog.example.com:8443") + for (const secret of ["catalog-user", "catalog-password", "private", "token", "query-secret", "hash-secret"]) + expect(diagnostic).not.toContain(secret) + }) + + test("does not expose local catalog paths", () => { + expect(catalogDiagnosticOrigin("/private/catalog/token/models.json", "file")).toBe("local catalog file") + }) + + test("build fetch failures do not print URL secrets or a native cause", async () => { + const packageDir = path.resolve(import.meta.dir, "../..") + const source = "http://catalog-user:catalog-password@127.0.0.1:9/private-token" + const proc = Bun.spawn([process.execPath, "run", "script/build.ts", "--single", "--skip-install"], { + cwd: packageDir, + env: { + ...process.env, + MODELS_DEV_API_JSON: "", + OPENCODE_MODELS_URL: source, + }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + const output = stdout + stderr + + expect(exitCode).not.toBe(0) + expect(output).toContain("models.dev fetch from http://127.0.0.1:9 failed") + for (const secret of ["catalog-user", "catalog-password", "private-token"]) expect(output).not.toContain(secret) + expect(output).not.toContain("cause:") + }) +}) From cf1011d912d0e9afa6a5747fae6ad8e76d3a18b7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 19:32:33 -0700 Subject: [PATCH 7/8] fix: validate required catalog model fields --- packages/opencode/script/models-catalog.ts | 29 +++++++++ .../provider/build-models-catalog.test.ts | 63 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/opencode/script/models-catalog.ts b/packages/opencode/script/models-catalog.ts index c30c09f1f6..9a26edfcce 100644 --- a/packages/opencode/script/models-catalog.ts +++ b/packages/opencode/script/models-catalog.ts @@ -18,6 +18,16 @@ function optionalStringProblem(value: unknown, where: string, allowEmpty = true) return undefined } +function requiredStringProblem(value: unknown, where: string): string | undefined { + if (typeof value !== "string" || value.length === 0) return `${where} is not a non-empty string` + return undefined +} + +function requiredBooleanProblem(value: unknown, where: string): string | undefined { + if (typeof value !== "boolean") return `${where} is not a boolean` + return undefined +} + function stringArrayProblem(value: unknown, where: string): string | undefined { if (value === undefined || value === null) return undefined if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${where} is not a string array` @@ -30,6 +40,9 @@ function providerEntryProblem(id: string, entry: unknown): string | undefined { if (typeof entry.id !== "string" || entry.id.length === 0) return `${id} (no non-empty string id)` if (entry.id !== id) return `${id} (id does not match catalog key)` + const nameProblem = requiredStringProblem(entry.name, `${id}.name`) + if (nameProblem) return nameProblem + const envProblem = stringArrayProblem(entry.env, `${id}.env`) if (envProblem) return envProblem const apiProblem = optionalStringProblem(entry.api, `${id}.api`) @@ -46,6 +59,22 @@ function providerEntryProblem(id: string, entry: unknown): string | undefined { if (typeof model.id !== "string" || model.id.length === 0) return `${where} (no non-empty string id)` if (model.id !== modelId) return `${where} (id does not match catalog key)` + for (const field of ["name", "release_date"] as const) { + const problem = requiredStringProblem(model[field], `${where}.${field}`) + if (problem) return problem + } + for (const field of ["attachment", "reasoning", "tool_call"] as const) { + const problem = requiredBooleanProblem(model[field], `${where}.${field}`) + if (problem) return problem + } + // models.dev currently omits `temperature` for some valid entries. Undefined + // is consumed as a falsey capability, but a present non-boolean still violates + // the runtime contract and must not be embedded. + if (model.temperature !== undefined) { + const temperatureProblem = requiredBooleanProblem(model.temperature, `${where}.temperature`) + if (temperatureProblem) return temperatureProblem + } + if (model.provider !== undefined && model.provider !== null) { if (!isRecord(model.provider)) return `${where}.provider is not an object` const modelApiProblem = optionalStringProblem(model.provider.api, `${where}.provider.api`) diff --git a/packages/opencode/test/provider/build-models-catalog.test.ts b/packages/opencode/test/provider/build-models-catalog.test.ts index 9fd17a60ab..d15e697fdd 100644 --- a/packages/opencode/test/provider/build-models-catalog.test.ts +++ b/packages/opencode/test/provider/build-models-catalog.test.ts @@ -8,6 +8,7 @@ type MutableCatalog = Record< string, { id: unknown + name?: unknown env?: unknown api?: unknown npm?: unknown @@ -15,6 +16,12 @@ type MutableCatalog = Record< string, { id: unknown + name?: unknown + release_date?: unknown + attachment?: unknown + reasoning?: unknown + temperature?: unknown + tool_call?: unknown provider?: unknown limit: Record modalities: Record @@ -27,12 +34,19 @@ function customCatalog(): MutableCatalog { return { acme: { id: "acme", + name: "Acme", env: ["ACME_API_KEY"], api: "https://api.example.com/v1", npm: "@ai-sdk/openai-compatible", models: { "acme-one": { id: "acme-one", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, limit: { context: 128_000, input: 120_000, output: 8_000 }, modalities: { input: ["text"], output: ["text"] }, }, @@ -50,9 +64,16 @@ function strictCatalog(): MutableCatalog { provider, { id: provider, + name: provider, models: { [model]: { id: model, + name: model, + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, limit: { context: 128_000, output: 8_000 }, modalities: {}, }, @@ -88,6 +109,13 @@ describe("build models catalog validation", () => { expect(summary.requiredModelCounts.google).toBeGreaterThan(0) }) + test("accepts an omitted temperature capability used by the live catalog", () => { + const catalog = customCatalog() + delete catalog.acme.models["acme-one"].temperature + + expect(() => assertUsableCatalog(JSON.stringify(catalog), diagnosticOrigin, false)).not.toThrow() + }) + for (const [label, body, message] of [ ["non-JSON", "bad gateway", "is not valid JSON"], ["an array", "[]", "is not a provider object"], @@ -109,6 +137,11 @@ describe("build models catalog validation", () => { mutate: (catalog) => (catalog.acme.id = "other"), message: "acme (id does not match catalog key)", }, + { + label: "a provider without a string name", + mutate: (catalog) => (catalog.acme.name = 42), + message: "acme.name is not a non-empty string", + }, { label: "a model without a string ID", mutate: (catalog) => (catalog.acme.models["acme-one"].id = 42), @@ -119,6 +152,36 @@ describe("build models catalog validation", () => { mutate: (catalog) => (catalog.acme.models["acme-one"].id = "other"), message: "acme/acme-one (id does not match catalog key)", }, + { + label: "a model without a name", + mutate: (catalog) => delete catalog.acme.models["acme-one"].name, + message: "acme/acme-one.name is not a non-empty string", + }, + { + label: "a model without a release date", + mutate: (catalog) => delete catalog.acme.models["acme-one"].release_date, + message: "acme/acme-one.release_date is not a non-empty string", + }, + { + label: "a model without an attachment capability", + mutate: (catalog) => delete catalog.acme.models["acme-one"].attachment, + message: "acme/acme-one.attachment is not a boolean", + }, + { + label: "a model without a reasoning capability", + mutate: (catalog) => delete catalog.acme.models["acme-one"].reasoning, + message: "acme/acme-one.reasoning is not a boolean", + }, + { + label: "a model without a tool-call capability", + mutate: (catalog) => delete catalog.acme.models["acme-one"].tool_call, + message: "acme/acme-one.tool_call is not a boolean", + }, + { + label: "a non-boolean temperature capability", + mutate: (catalog) => (catalog.acme.models["acme-one"].temperature = "yes"), + message: "acme/acme-one.temperature is not a boolean", + }, { label: "a non-array provider env", mutate: (catalog) => (catalog.acme.env = "ACME_API_KEY"), From b044ab0e64db9834489797448a2e5ac29b261189 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 19:36:44 -0700 Subject: [PATCH 8/8] fix: reject unsafe catalog prototype keys --- packages/opencode/script/models-catalog.ts | 5 +++++ .../test/provider/build-models-catalog.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/opencode/script/models-catalog.ts b/packages/opencode/script/models-catalog.ts index 9a26edfcce..e4fe508db8 100644 --- a/packages/opencode/script/models-catalog.ts +++ b/packages/opencode/script/models-catalog.ts @@ -36,6 +36,10 @@ function stringArrayProblem(value: unknown, where: string): string | undefined { /** Describe the first runtime-relevant structural problem in a provider entry. */ function providerEntryProblem(id: string, entry: unknown): string | undefined { + // build.ts emits the source JSON as a JavaScript object literal, where this + // otherwise-valid JSON key mutates the object's prototype instead of creating + // an own property. Reject it so the validated and emitted catalogs are equal. + if (id === "__proto__") return `${id} (reserved catalog key)` if (!isRecord(entry)) return `${id} (not an object)` if (typeof entry.id !== "string" || entry.id.length === 0) return `${id} (no non-empty string id)` if (entry.id !== id) return `${id} (id does not match catalog key)` @@ -55,6 +59,7 @@ function providerEntryProblem(id: string, entry: unknown): string | undefined { for (const [modelId, model] of Object.entries(entry.models)) { const where = `${id}/${modelId}` + if (modelId === "__proto__") return `${where} (reserved catalog key)` if (!isRecord(model)) return `${where} (not an object)` if (typeof model.id !== "string" || model.id.length === 0) return `${where} (no non-empty string id)` if (model.id !== modelId) return `${where} (id does not match catalog key)` diff --git a/packages/opencode/test/provider/build-models-catalog.test.ts b/packages/opencode/test/provider/build-models-catalog.test.ts index d15e697fdd..d1a2f2b2aa 100644 --- a/packages/opencode/test/provider/build-models-catalog.test.ts +++ b/packages/opencode/test/provider/build-models-catalog.test.ts @@ -137,6 +137,15 @@ describe("build models catalog validation", () => { mutate: (catalog) => (catalog.acme.id = "other"), message: "acme (id does not match catalog key)", }, + { + label: "a provider using the reserved __proto__ key", + mutate: (catalog) => + Object.defineProperty(catalog, "__proto__", { + enumerable: true, + value: { ...catalog.acme, id: "__proto__" }, + }), + message: "__proto__ (reserved catalog key)", + }, { label: "a provider without a string name", mutate: (catalog) => (catalog.acme.name = 42), @@ -152,6 +161,15 @@ describe("build models catalog validation", () => { mutate: (catalog) => (catalog.acme.models["acme-one"].id = "other"), message: "acme/acme-one (id does not match catalog key)", }, + { + label: "a model using the reserved __proto__ key", + mutate: (catalog) => + Object.defineProperty(catalog.acme.models, "__proto__", { + enumerable: true, + value: { ...catalog.acme.models["acme-one"], id: "__proto__" }, + }), + message: "acme/__proto__ (reserved catalog key)", + }, { label: "a model without a name", mutate: (catalog) => delete catalog.acme.models["acme-one"].name,