Skip to content

Commit 5993471

Browse files
anandgupta42claude
andauthored
fix: build release binaries against the live models.dev catalog (#1188)
* fix: build release binaries against the live models.dev catalog `.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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ * fix: harden the catalog guard against hangs and hollow payloads 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<string, unknown>`. 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ * fix: repair the empty-env regression and harden the catalog guard further 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ * fix: validate nested model records before embedding the catalog 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ * fix: give the fetch backstop a margin and stop overclaiming what it covers Two review findings on the backstop added in eeea7c9, 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ * fix: validate release model catalog safely * fix: validate required catalog model fields * fix: reject unsafe catalog prototype keys --------- Co-authored-by: Claude Opus 5 <[email protected]>
1 parent c59a5a2 commit 5993471

4 files changed

Lines changed: 595 additions & 6 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,12 @@ jobs:
9898
OPENCODE_CHANNEL: ${{ contains(github.ref_name, '-') && 'beta' || 'latest' }}
9999
OPENCODE_RELEASE: "1"
100100
GH_REPO: ${{ env.GH_REPO }}
101-
MODELS_DEV_API_JSON: test/tool/fixtures/models-api.json
101+
# altimate_change — MODELS_DEV_API_JSON is deliberately NOT set here.
102+
# Pointing it at test/tool/fixtures/models-api.json (as ci.yml does, where
103+
# a hermetic build is correct) made every shipped binary embed that fixture
104+
# as its bundled models.dev catalog — newest entry 2026-03-30. Release
105+
# builds fetch models.dev live, matching upstream's publish.yml. build.ts
106+
# validates the payload and fails the build if it is unusable.
102107

103108
# Smoke-test: verify the compiled binary actually starts.
104109
# Only possible for native linux-x64 builds on the ubuntu runner.

‎packages/opencode/script/build.ts‎

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ process.chdir(dir)
1818
import { Script } from "@opencode-ai/script"
1919
import pkg from "../package.json"
2020
import { walkInputs } from "./stamp-inputs"
21+
import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "./models-catalog"
2122

2223
// Python engine has been eliminated — all methods run natively in TypeScript.
2324
// ALTIMATE_ENGINE_VERSION is no longer needed at runtime.
@@ -27,11 +28,95 @@ const changelogPath = path.resolve(dir, "../../CHANGELOG.md")
2728
const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).text() : ""
2829
console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`)
2930

30-
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
31-
// Fetch and generate models.dev snapshot
32-
const modelsData = process.env.MODELS_DEV_API_JSON
33-
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
34-
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
31+
const modelsUrlOverride = process.env.OPENCODE_MODELS_URL || undefined
32+
const modelsUrl = modelsUrlOverride ?? "https://models.dev"
33+
34+
const CATALOG_FETCH_TIMEOUT_MS = 60_000
35+
// The hard backstop must lose the race to `AbortSignal.timeout` in every case the
36+
// signal CAN handle, or it fires first and replaces the precise per-stage message
37+
// ("fetch failed", "body read failed") with its own generic one. The margin is
38+
// what makes it a backstop rather than the primary timeout.
39+
const CATALOG_HARD_DEADLINE_MS = CATALOG_FETCH_TIMEOUT_MS + 15_000
40+
41+
/** Fetch the models.dev catalog, failing loudly rather than hanging or
42+
* returning an error page.
43+
*
44+
* `fetch` resolves for 4xx/5xx, so without the `res.ok` check a load-balancer
45+
* error page flows straight into the snapshot. An HTML body would at least break
46+
* the build at parse time, but a JSON error body (`{"error": ...}`) is valid
47+
* TypeScript and would ship as a catalog with no providers in it. */
48+
async function fetchModelsCatalog(url: string, diagnosticOrigin: string): Promise<string> {
49+
// Backstop for the case where the abort signal fires but the fetch promise never
50+
// settles, so the `catch` below is never reached. `AbortSignal.timeout` cannot
51+
// cancel a blocked `getaddrinfo()` — documented in src/provider/models.ts
52+
// (#1052 D14), where a sandboxed-network DNS blackhole outlived the signal.
53+
//
54+
// HONEST LIMIT: this is a timer on the event loop, so it cannot preempt a
55+
// genuinely blocked main thread either. If `getaddrinfo` blocks the loop
56+
// outright, neither the signal nor this fires and the workflow `timeout-minutes`
57+
// stays the real backstop. What this does cover is the more common shape — the
58+
// loop still ticking while a request hangs unresolved — turning a silent
59+
// full-length job timeout into a fast, labelled failure. Either way the build
60+
// fails; it never falls through to a stale catalog.
61+
const deadline = setTimeout(() => {
62+
console.error(
63+
`error: models.dev fetch from ${diagnosticOrigin} did not settle within ${CATALOG_HARD_DEADLINE_MS}ms ` +
64+
`(host unreachable or unresolvable); failing the build`,
65+
)
66+
process.exit(1)
67+
}, CATALOG_HARD_DEADLINE_MS)
68+
try {
69+
let res: Response
70+
try {
71+
res = await fetch(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) })
72+
} catch {
73+
throw new Error(
74+
`models.dev fetch from ${diagnosticOrigin} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`,
75+
)
76+
}
77+
if (!res.ok)
78+
throw new Error(`models.dev fetch failed: HTTP ${res.status} ${res.statusText} from ${diagnosticOrigin}`)
79+
try {
80+
// Inside its own try: a host that sends headers promptly then stalls
81+
// mid-body aborts here, and an uncaught abort surfaces as a bare
82+
// AbortError carrying none of the context above.
83+
return await res.text()
84+
} catch {
85+
throw new Error(
86+
`models.dev body read from ${diagnosticOrigin} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`,
87+
)
88+
}
89+
} finally {
90+
clearTimeout(deadline)
91+
}
92+
}
93+
94+
async function readModelsCatalog(file: string, diagnosticOrigin: string): Promise<string> {
95+
try {
96+
return await Bun.file(file).text()
97+
} catch {
98+
throw new Error(`models.dev catalog read from ${diagnosticOrigin} failed`)
99+
}
100+
}
101+
102+
// Fetch and generate models.dev snapshot. MODELS_DEV_API_JSON pins the catalog to
103+
// a local file for hermetic builds (ci.yml, pre-release-check.ts); release builds
104+
// leave it unset so the shipped binary embeds a release-time catalog.
105+
// `|| undefined` rather than `??`: an env var that is SET BUT EMPTY has to read as
106+
// unset, or the origin keeps "" while the data branch falls through to the fetch
107+
// and the build dies on `fetch("")` with ERR_INVALID_URL.
108+
const modelsFile = process.env.MODELS_DEV_API_JSON || undefined
109+
const modelsOrigin = modelsFile ?? `${modelsUrl}/api.json`
110+
const modelsDiagnosticOrigin = catalogDiagnosticOrigin(modelsOrigin, modelsFile ? "file" : "url")
111+
const modelsData = modelsFile
112+
? await readModelsCatalog(modelsFile, modelsDiagnosticOrigin)
113+
: await fetchModelsCatalog(modelsOrigin, modelsDiagnosticOrigin)
114+
// A release is held to the full floor however its catalog was sourced, so pointing
115+
// a release build at a custom catalog cannot quietly skip the size and
116+
// required-provider checks.
117+
const strictCatalog = !!process.env.OPENCODE_RELEASE || (!modelsFile && !modelsUrlOverride)
118+
const catalogSummary = assertUsableCatalog(modelsData, modelsDiagnosticOrigin, strictCatalog)
119+
console.log(formatCatalogSummary(catalogSummary, modelsDiagnosticOrigin))
35120
await Bun.write(
36121
path.join(dir, "src/provider/models-snapshot.ts"),
37122
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData.trim()} as const\n`,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
export const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] as const
2+
export const MIN_CATALOG_PROVIDERS = 50
3+
4+
type CatalogSummary = {
5+
providerCount: number
6+
requiredModelCounts: Record<string, number>
7+
strict: boolean
8+
}
9+
10+
function isRecord(value: unknown): value is Record<string, unknown> {
11+
return typeof value === "object" && value !== null && !Array.isArray(value)
12+
}
13+
14+
function optionalStringProblem(value: unknown, where: string, allowEmpty = true): string | undefined {
15+
if (value === undefined || value === null) return undefined
16+
if (typeof value !== "string") return `${where} is not a string`
17+
if (!allowEmpty && value.length === 0) return `${where} is empty`
18+
return undefined
19+
}
20+
21+
function requiredStringProblem(value: unknown, where: string): string | undefined {
22+
if (typeof value !== "string" || value.length === 0) return `${where} is not a non-empty string`
23+
return undefined
24+
}
25+
26+
function requiredBooleanProblem(value: unknown, where: string): string | undefined {
27+
if (typeof value !== "boolean") return `${where} is not a boolean`
28+
return undefined
29+
}
30+
31+
function stringArrayProblem(value: unknown, where: string): string | undefined {
32+
if (value === undefined || value === null) return undefined
33+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${where} is not a string array`
34+
return undefined
35+
}
36+
37+
/** Describe the first runtime-relevant structural problem in a provider entry. */
38+
function providerEntryProblem(id: string, entry: unknown): string | undefined {
39+
// build.ts emits the source JSON as a JavaScript object literal, where this
40+
// otherwise-valid JSON key mutates the object's prototype instead of creating
41+
// an own property. Reject it so the validated and emitted catalogs are equal.
42+
if (id === "__proto__") return `${id} (reserved catalog key)`
43+
if (!isRecord(entry)) return `${id} (not an object)`
44+
if (typeof entry.id !== "string" || entry.id.length === 0) return `${id} (no non-empty string id)`
45+
if (entry.id !== id) return `${id} (id does not match catalog key)`
46+
47+
const nameProblem = requiredStringProblem(entry.name, `${id}.name`)
48+
if (nameProblem) return nameProblem
49+
50+
const envProblem = stringArrayProblem(entry.env, `${id}.env`)
51+
if (envProblem) return envProblem
52+
const apiProblem = optionalStringProblem(entry.api, `${id}.api`)
53+
if (apiProblem) return apiProblem
54+
const npmProblem = optionalStringProblem(entry.npm, `${id}.npm`, false)
55+
if (npmProblem) return npmProblem
56+
57+
if (!("models" in entry)) return `${id} (no models)`
58+
if (!isRecord(entry.models)) return `${id} (models is not a map)`
59+
60+
for (const [modelId, model] of Object.entries(entry.models)) {
61+
const where = `${id}/${modelId}`
62+
if (modelId === "__proto__") return `${where} (reserved catalog key)`
63+
if (!isRecord(model)) return `${where} (not an object)`
64+
if (typeof model.id !== "string" || model.id.length === 0) return `${where} (no non-empty string id)`
65+
if (model.id !== modelId) return `${where} (id does not match catalog key)`
66+
67+
for (const field of ["name", "release_date"] as const) {
68+
const problem = requiredStringProblem(model[field], `${where}.${field}`)
69+
if (problem) return problem
70+
}
71+
for (const field of ["attachment", "reasoning", "tool_call"] as const) {
72+
const problem = requiredBooleanProblem(model[field], `${where}.${field}`)
73+
if (problem) return problem
74+
}
75+
// models.dev currently omits `temperature` for some valid entries. Undefined
76+
// is consumed as a falsey capability, but a present non-boolean still violates
77+
// the runtime contract and must not be embedded.
78+
if (model.temperature !== undefined) {
79+
const temperatureProblem = requiredBooleanProblem(model.temperature, `${where}.temperature`)
80+
if (temperatureProblem) return temperatureProblem
81+
}
82+
83+
if (model.provider !== undefined && model.provider !== null) {
84+
if (!isRecord(model.provider)) return `${where}.provider is not an object`
85+
const modelApiProblem = optionalStringProblem(model.provider.api, `${where}.provider.api`)
86+
if (modelApiProblem) return modelApiProblem
87+
const modelNpmProblem = optionalStringProblem(model.provider.npm, `${where}.provider.npm`, false)
88+
if (modelNpmProblem) return modelNpmProblem
89+
}
90+
91+
if (!isRecord(model.limit)) return `${where} (limit is not an object)`
92+
if (typeof model.limit.context !== "number") return `${where} (limit.context is not a number)`
93+
if (typeof model.limit.output !== "number") return `${where} (limit.output is not a number)`
94+
if (model.limit.input !== undefined && model.limit.input !== null && typeof model.limit.input !== "number")
95+
return `${where} (limit.input is not a number)`
96+
97+
if (model.modalities !== undefined && model.modalities !== null) {
98+
if (!isRecord(model.modalities)) return `${where}.modalities is not an object`
99+
const inputProblem = stringArrayProblem(model.modalities.input, `${where}.modalities.input`)
100+
if (inputProblem) return inputProblem
101+
const outputProblem = stringArrayProblem(model.modalities.output, `${where}.modalities.output`)
102+
if (outputProblem) return outputProblem
103+
}
104+
}
105+
return undefined
106+
}
107+
108+
/** Return a log-safe description without URL userinfo, path, query or fragment. */
109+
export function catalogDiagnosticOrigin(source: string, kind: "file" | "url"): string {
110+
if (kind === "file") return "local catalog file"
111+
try {
112+
const url = new URL(source)
113+
if (url.protocol !== "http:" && url.protocol !== "https:") return "custom catalog endpoint"
114+
return url.origin
115+
} catch {
116+
return "custom catalog endpoint"
117+
}
118+
}
119+
120+
/** Reject a catalog that parses but cannot be consumed safely at runtime. */
121+
export function assertUsableCatalog(text: string, diagnosticOrigin: string, strict: boolean): CatalogSummary {
122+
let parsed: unknown
123+
try {
124+
parsed = JSON.parse(text)
125+
} catch {
126+
throw new Error(`models.dev catalog from ${diagnosticOrigin} is not valid JSON`)
127+
}
128+
if (!isRecord(parsed)) throw new Error(`models.dev catalog from ${diagnosticOrigin} is not a provider object`)
129+
130+
const catalog = new Map<string, unknown>(Object.entries(parsed))
131+
if (catalog.size === 0) throw new Error(`models.dev catalog from ${diagnosticOrigin} is empty`)
132+
133+
const problems = [...catalog.entries()]
134+
.map(([id, entry]) => providerEntryProblem(id, entry))
135+
.filter((problem): problem is string => problem !== undefined)
136+
if (problems.length > 0)
137+
throw new Error(
138+
`models.dev catalog from ${diagnosticOrigin} has ${problems.length} malformed provider entries: ` +
139+
`${problems.slice(0, 5).join(", ")}${problems.length > 5 ? ", …" : ""}`,
140+
)
141+
142+
const modelCount = (id: string): number => {
143+
const entry = catalog.get(id)
144+
if (!isRecord(entry) || !isRecord(entry.models)) return 0
145+
return Object.keys(entry.models).length
146+
}
147+
148+
if (strict) {
149+
if (catalog.size < MIN_CATALOG_PROVIDERS)
150+
throw new Error(
151+
`models.dev catalog from ${diagnosticOrigin} has only ${catalog.size} providers, ` +
152+
`expected at least ${MIN_CATALOG_PROVIDERS}`,
153+
)
154+
const missing = REQUIRED_CATALOG_PROVIDERS.filter((provider) => !catalog.has(provider))
155+
if (missing.length > 0)
156+
throw new Error(
157+
`models.dev catalog from ${diagnosticOrigin} is missing required providers: ${missing.join(", ")}`,
158+
)
159+
const empty = REQUIRED_CATALOG_PROVIDERS.filter((provider) => modelCount(provider) === 0)
160+
if (empty.length > 0)
161+
throw new Error(`models.dev catalog from ${diagnosticOrigin} has no usable models for: ${empty.join(", ")}`)
162+
}
163+
164+
return {
165+
providerCount: catalog.size,
166+
requiredModelCounts: Object.fromEntries(
167+
REQUIRED_CATALOG_PROVIDERS.map((provider) => [provider, modelCount(provider)]),
168+
),
169+
strict,
170+
}
171+
}
172+
173+
export function formatCatalogSummary(summary: CatalogSummary, diagnosticOrigin: string): string {
174+
if (!summary.strict)
175+
return `models.dev catalog from ${diagnosticOrigin}: ${summary.providerCount} providers (custom catalog, size floor not applied)`
176+
return (
177+
`models.dev catalog from ${diagnosticOrigin}: ${summary.providerCount} providers ` +
178+
`(${REQUIRED_CATALOG_PROVIDERS.map((provider) => `${provider}=${summary.requiredModelCounts[provider]}`).join(
179+
", ",
180+
)})`
181+
)
182+
}

0 commit comments

Comments
 (0)