Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
packages/core/src/models-dev/snapshot.txt linguist-generated
packages/core/src/**/*.txt text eol=lf
24 changes: 21 additions & 3 deletions packages/cli/script/build-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { build } from "vite"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
Expand Down Expand Up @@ -58,6 +57,25 @@ const builder =
: undefined
const appArchive = await buildAppArchive(Script.channel)

// Vite silently rewrites text imports of known asset types (.txt) to asset
// URL strings when the raw-text plugin doesn't intercept them first — the
// bundle still builds and `--help` still runs, so only content assertions
// catch it. Guards the models.dev snapshot and the prompt/tool description
// text that ships inside the bundle.
async function assertTextImportsInlined(bundlePath: string) {
const bundle = await readFile(bundlePath, "utf8")
const markers = [
{ marker: '"zhipuai"', source: "models-dev snapshot" },
{ marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true },
{ marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true },
]
for (const { marker, source, forbidden } of markers) {
const present = bundle.includes(marker)
if (forbidden ? present : !present)
throw new Error(`${bundlePath}: ${source} — text imports are not inlined as content (marker ${marker})`)
}
}

for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
Expand All @@ -66,13 +84,13 @@ for (const target of targets) {
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appArchive,
}
await copyNodeAssets(assets)
await build(mainConfig(input))
await assertTextImportsInlined("dist-node/opencode.mjs")

const host = target.platform === process.platform && target.arch === process.arch
if (host) {
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { buildAppArchive } from "./app-assets"

const dir = path.resolve(import.meta.dirname, "..")
Expand Down Expand Up @@ -115,7 +114,6 @@ for (const item of targets) {
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
Expand Down
9 changes: 0 additions & 9 deletions packages/cli/script/generate.ts

This file was deleted.

10 changes: 6 additions & 4 deletions packages/cli/vite.node.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ const dir = import.meta.dirname
function rawTextPlugin(): Plugin {
return {
name: "opencode:raw-text",
// "pre" is load-bearing for .txt: Vite's built-in asset plugin claims
// known asset types (.txt among them) ahead of normal-priority plugins,
// replacing the import with an asset URL string instead of the content.
// .md only ever worked without it because .md is not a known asset type.
enforce: "pre",
async load(id) {
if (!id.endsWith(".md")) return
if (!id.endsWith(".md") && !id.endsWith(".txt")) return
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
},
}
Expand Down Expand Up @@ -222,7 +227,6 @@ if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
export type NodeBuildInput = {
readonly version: string
readonly channel: string
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appArchive: string
Expand All @@ -248,7 +252,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
define: {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_MODELS_DEV: input.models,
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
Expand All @@ -271,7 +274,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
export default mainConfig({
version: process.env.OPENCODE_VERSION ?? "local",
channel: process.env.OPENCODE_CHANNEL ?? "local",
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
Expand Down
24 changes: 24 additions & 0 deletions packages/core/script/update-models-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
/**
* Refreshes the bundled models.dev catalog snapshot at src/models-dev/snapshot.txt.
* The snapshot is the boot-time floor for the catalog when no cache entry exists
* and fetching is disabled or unavailable; live fetch still refreshes on top.
*/
const source = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const response = await fetch(`${source}/api.json`)
if (!response.ok) {
console.error(`Failed to fetch ${source}/api.json: ${response.status} ${response.statusText}`)
process.exit(1)
}
const text = await response.text()
const parsed: unknown = JSON.parse(text)
// A floor, not equality: guards against committing an error page or a
// truncated body that still parses as a small object.
const MINIMUM_PROVIDERS = 100
if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length < MINIMUM_PROVIDERS) {
console.error(`Fetched catalog has fewer than ${MINIMUM_PROVIDERS} providers; refusing to write snapshot`)
process.exit(1)
}
const target = new URL("../src/models-dev/snapshot.txt", import.meta.url)
await Bun.write(target, text)
console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${Bun.fileURLToPath(target)}`)
18 changes: 12 additions & 6 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
import { KV } from "./kv.js"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }

export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
Expand Down Expand Up @@ -519,8 +520,6 @@ function modelInfo(

export { Event } from "@opencode-ai/schema/models-dev"

declare const OPENCODE_MODELS_DEV: Record<string, SourceProvider> | undefined

export interface Interface {
readonly get: () => Effect.Effect<readonly Snapshot[]>
readonly refresh: (force?: boolean) => Effect.Effect<void>
Expand All @@ -530,12 +529,17 @@ export const Options = Schema.Struct({
url: Schema.optional(Schema.String),
file: Schema.optional(Schema.String),
fetch: Schema.optional(Schema.Boolean),
snapshot: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type

export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}

const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(
Effect.map((catalog) => catalog as Record<string, SourceProvider>),
)
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
Expand Down Expand Up @@ -605,13 +609,15 @@ export const layer = (options?: Options) =>
)
: Effect.succeed(undefined)

const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. It is the boot-time floor
// for the catalog; the periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)

const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
const catalog = yield* decodeCatalog(text)
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/models-dev/snapshot.txt

Large diffs are not rendered by default.

27 changes: 21 additions & 6 deletions packages/core/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,20 @@ describe("ModelsDev Service", () => {
}),
)

it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(
Effect.provide(buildLayer(state, cache, { fetch: false, snapshot: false })),
)
expect(result).toEqual([])
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)

it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
Expand All @@ -237,7 +250,9 @@ describe("ModelsDev Service", () => {
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual([])
expect(result.length).toBeGreaterThan(0)
const anthropic = result.find((snapshot) => snapshot.info.id === "anthropic")
expect(anthropic?.environment).toContain("ANTHROPIC_API_KEY")
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
Expand All @@ -248,7 +263,7 @@ describe("ModelsDev Service", () => {
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
Expand All @@ -263,7 +278,7 @@ describe("ModelsDev Service", () => {
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
Expand All @@ -281,7 +296,7 @@ describe("ModelsDev Service", () => {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true, snapshot: false })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
Expand All @@ -296,7 +311,7 @@ describe("ModelsDev Service", () => {
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true, snapshot: false })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
Expand Down
Loading