Skip to content
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
anandgupta42 marked this conversation as resolved.
Comment thread
anandgupta42 marked this conversation as resolved.
# 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Smoke-test: verify the compiled binary actually starts.
# Only possible for native linux-x64 builds on the ubuntu runner.
Expand Down
95 changes: 90 additions & 5 deletions packages/opencode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,11 +28,95 @@ 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"
// Fetch and generate models.dev snapshot
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())
const modelsUrlOverride = process.env.OPENCODE_MODELS_URL || undefined
const modelsUrl = modelsUrlOverride ?? "https://models.dev"

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

/** Fetch the models.dev catalog, failing loudly rather than hanging or
* returning an error page.
*
* `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, diagnosticOrigin: string): Promise<string> {
// 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(() => {
Comment thread
anandgupta42 marked this conversation as resolved.
Comment thread
anandgupta42 marked this conversation as resolved.
console.error(
`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)
}, CATALOG_HARD_DEADLINE_MS)
try {
let res: Response
try {
res = await fetch(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) })
} 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 ${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 {
throw new Error(
`models.dev body read from ${diagnosticOrigin} failed or timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`,
)
}
} finally {
clearTimeout(deadline)
}
}

async function readModelsCatalog(file: string, diagnosticOrigin: string): Promise<string> {
try {
return await Bun.file(file).text()
} catch {
throw new Error(`models.dev catalog read from ${diagnosticOrigin} failed`)
}
}

// 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.
// `|| 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 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)
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`,
Comment thread
anandgupta42 marked this conversation as resolved.
Expand Down
182 changes: 182 additions & 0 deletions packages/opencode/script/models-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
export const REQUIRED_CATALOG_PROVIDERS = ["anthropic", "openai", "google"] as const
export const MIN_CATALOG_PROVIDERS = 50

type CatalogSummary = {
providerCount: number
requiredModelCounts: Record<string, number>
strict: boolean
}

function isRecord(value: unknown): value is Record<string, unknown> {
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
Comment thread
anandgupta42 marked this conversation as resolved.
}

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`
return 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)`
Comment thread
anandgupta42 marked this conversation as resolved.

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`)
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 (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)`
Comment thread
anandgupta42 marked this conversation as resolved.
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
}
Comment on lines +67 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-string model families

When the live response or a catalog override supplies an otherwise-valid model with a truthy non-string family (for example, 42 or {}), this validator accepts and embeds it. fromModelsDevModel copies that value unchanged, and normal prompt processing calls familyVendor, which executes family.toLowerCase() (src/provider/family.ts:18-20), so starting a session with the affected model throws before the LLM request is prepared. Validate family as a string whenever it is present.

Useful? React with 👍 / 👎.

for (const field of ["attachment", "reasoning", "tool_call"] as const) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Requiring attachment, reasoning, tool_call (and release_date on line 67) in every mode is stricter than the runtime consumes them, and can reject minimal custom catalogs

fromModelsDevModel copies these fields with no ?? guard (provider.ts:1042/1076-1079/1096), but their absence degrades gracefully instead of crashing: release_date falls back through openaiReasoningEfforts(id, releaseDate = "") (transform.ts:527) and "" is already a valid sentinel (provider.ts:236,263,1567), while attachment/reasoning/tool_call are only read in truthy contexts (if (!model.capabilities.reasoning)), so undefined merely downgrades the capability. Unlike limit.context (dereferenced with no guard) and name (ACP a.name.localeCompare), these fields never crash when missing.

Because providerEntryProblem has no strict flag, these checks also run for custom catalogs via OPENCODE_MODELS_URL / MODELS_DEV_API_JSON — the exact path the strict-mode scoping was added to preserve. A minimal private catalog carrying only id/models/limit (which the runtime consumes fine) now fails the build, and the PR description documents the "every mode" contract as only id + models + limit.context.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate catalog pricing records before embedding

When an otherwise-valid model contains a malformed optional price such as cost: { input: {}, output: 1 }, the validator never inspects cost and accepts the snapshot. fromModelsDevModel copies this value into Provider.Model.cost, and usage accounting passes it directly to Decimal.mul in src/session/session.ts:445-452, which throws after an LLM response instead of recording the completed turn. Validate every present cost record, including the nested context_over_200k prices, as finite numeric fields.

Useful? React with 👍 / 👎.

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)`
Comment thread
anandgupta42 marked this conversation as resolved.
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<string, unknown>(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(", ")}`)
Comment on lines +159 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count only selectable models for required providers

When each required provider has one structurally valid model marked status: "deprecated", modelCount is nonzero and strict validation accepts the catalog. Provider initialization later unconditionally deletes deprecated models in src/provider/provider.ts:1719-1727, leaving anthropic, openai, and google with no selectable models despite this guard reporting them as usable. Require at least one model per required provider that survives the runtime status filter.

Useful? React with 👍 / 👎.

}

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(
", ",
)})`
)
}
Loading
Loading