1
0
Эх сурвалжийг харах

core: embed models.dev snapshot instead of compile-time define (#41838)

Kit Langton 4 өдөр өмнө
parent
commit
c254ba8a7f

+ 1 - 0
.gitattributes

@@ -1,3 +1,4 @@
 packages/core/migration/**/snapshot.json linguist-generated
 packages/core/migration/**/snapshot.json linguist-generated
 packages/core/src/database/migration.gen.ts 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
 packages/core/src/**/*.txt text eol=lf

+ 21 - 3
packages/cli/script/build-node.ts

@@ -2,13 +2,12 @@
 
 
 import { spawnSync } from "node:child_process"
 import { spawnSync } from "node:child_process"
 import { createHash } from "node:crypto"
 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 os from "node:os"
 import path from "node:path"
 import path from "node:path"
 import { build } from "vite"
 import { build } from "vite"
 import { Script } from "@opencode-ai/script"
 import { Script } from "@opencode-ai/script"
 import pkg from "../package.json"
 import pkg from "../package.json"
-import { modelsData } from "./generate"
 import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
 import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
 import { mainConfig } from "../vite.node.config"
 import { mainConfig } from "../vite.node.config"
 import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
 import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
@@ -58,6 +57,25 @@ const builder =
     : undefined
     : undefined
 const appArchive = await buildAppArchive(Script.channel)
 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) {
 for (const target of targets) {
   console.log(`building cli-node-${targetName(target)}`)
   console.log(`building cli-node-${targetName(target)}`)
   const assets = await collectNodeAssets(target)
   const assets = await collectNodeAssets(target)
@@ -66,13 +84,13 @@ for (const target of targets) {
   const input = {
   const input = {
     version: Script.version,
     version: Script.version,
     channel: Script.channel,
     channel: Script.channel,
-    models: modelsData,
     assetHash,
     assetHash,
     target,
     target,
     appArchive,
     appArchive,
   }
   }
   await copyNodeAssets(assets)
   await copyNodeAssets(assets)
   await build(mainConfig(input))
   await build(mainConfig(input))
+  await assertTextImportsInlined("dist-node/opencode.mjs")
 
 
   const host = target.platform === process.platform && target.arch === process.arch
   const host = target.platform === process.platform && target.arch === process.arch
   if (host) {
   if (host) {

+ 0 - 2
packages/cli/script/build.ts

@@ -7,7 +7,6 @@ import { Script } from "@opencode-ai/script"
 import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
 import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
 import type { BunPlugin } from "bun"
 import type { BunPlugin } from "bun"
 import pkg from "../package.json"
 import pkg from "../package.json"
-import { modelsData } from "./generate"
 import { buildAppArchive } from "./app-assets"
 import { buildAppArchive } from "./app-assets"
 
 
 const dir = path.resolve(import.meta.dirname, "..")
 const dir = path.resolve(import.meta.dirname, "..")
@@ -115,7 +114,6 @@ for (const item of targets) {
     define: {
     define: {
       OPENCODE_VERSION: `'${Script.version}'`,
       OPENCODE_VERSION: `'${Script.version}'`,
       OPENCODE_CLI_NAME: `'${binary}'`,
       OPENCODE_CLI_NAME: `'${binary}'`,
-      OPENCODE_MODELS_DEV: modelsData,
       OPENCODE_CHANNEL: `'${Script.channel}'`,
       OPENCODE_CHANNEL: `'${Script.channel}'`,
       OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
       OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
       // FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
       // FFF_LIBC selects the fff native lib variant: "musl" or "gnu".

+ 0 - 9
packages/cli/script/generate.ts

@@ -1,9 +0,0 @@
-import { readFile } from "node:fs/promises"
-
-const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
-
-export const modelsData = process.env.MODELS_DEV_API_JSON
-  ? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
-  : await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
-
-console.log("Loaded models.dev snapshot")

+ 6 - 4
packages/cli/vite.node.config.ts

@@ -10,8 +10,13 @@ const dir = import.meta.dirname
 function rawTextPlugin(): Plugin {
 function rawTextPlugin(): Plugin {
   return {
   return {
     name: "opencode:raw-text",
     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) {
     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"))}`
       return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
     },
     },
   }
   }
@@ -222,7 +227,6 @@ if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
 export type NodeBuildInput = {
 export type NodeBuildInput = {
   readonly version: string
   readonly version: string
   readonly channel: string
   readonly channel: string
-  readonly models: string
   readonly assetHash: string
   readonly assetHash: string
   readonly target: NodeTarget
   readonly target: NodeTarget
   readonly appArchive: string
   readonly appArchive: string
@@ -248,7 +252,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
     define: {
     define: {
       OPENCODE_VERSION: JSON.stringify(input.version),
       OPENCODE_VERSION: JSON.stringify(input.version),
       OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
       OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
-      OPENCODE_MODELS_DEV: input.models,
       OPENCODE_CHANNEL: JSON.stringify(input.channel),
       OPENCODE_CHANNEL: JSON.stringify(input.channel),
       OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
       OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
       FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
       FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
@@ -271,7 +274,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
 export default mainConfig({
 export default mainConfig({
   version: process.env.OPENCODE_VERSION ?? "local",
   version: process.env.OPENCODE_VERSION ?? "local",
   channel: process.env.OPENCODE_CHANNEL ?? "local",
   channel: process.env.OPENCODE_CHANNEL ?? "local",
-  models: "undefined",
   assetHash: "local",
   assetHash: "local",
   target: nodeTarget(process.platform, process.arch),
   target: nodeTarget(process.platform, process.arch),
   appArchive: "",
   appArchive: "",

+ 1 - 0
packages/core/package.json

@@ -10,6 +10,7 @@
     "migration": "bun run script/migration.ts",
     "migration": "bun run script/migration.ts",
     "fix-node-pty": "bun run script/fix-node-pty.ts",
     "fix-node-pty": "bun run script/fix-node-pty.ts",
     "benchmark:location": "bun run script/benchmark-location.ts",
     "benchmark:location": "bun run script/benchmark-location.ts",
+    "update-models-snapshot": "bun run script/update-models-snapshot.ts",
     "test": "bun test --only-failures",
     "test": "bun test --only-failures",
     "typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
     "typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
   },
   },

+ 24 - 0
packages/core/script/update-models-snapshot.ts

@@ -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)}`)

+ 12 - 6
packages/core/src/models-dev.ts

@@ -11,6 +11,7 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
 import { Model } from "./model.js"
 import { Model } from "./model.js"
 import { Provider } from "./provider.js"
 import { Provider } from "./provider.js"
 import { KV } from "./kv.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 const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
 export type CatalogModelStatus = typeof CatalogModelStatus.Type
 export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -519,8 +520,6 @@ function modelInfo(
 
 
 export { Event } from "@opencode-ai/schema/models-dev"
 export { Event } from "@opencode-ai/schema/models-dev"
 
 
-declare const OPENCODE_MODELS_DEV: Record<string, SourceProvider> | undefined
-
 export interface Interface {
 export interface Interface {
   readonly get: () => Effect.Effect<readonly Snapshot[]>
   readonly get: () => Effect.Effect<readonly Snapshot[]>
   readonly refresh: (force?: boolean) => Effect.Effect<void>
   readonly refresh: (force?: boolean) => Effect.Effect<void>
@@ -530,12 +529,17 @@ export const Options = Schema.Struct({
   url: Schema.optional(Schema.String),
   url: Schema.optional(Schema.String),
   file: Schema.optional(Schema.String),
   file: Schema.optional(Schema.String),
   fetch: Schema.optional(Schema.Boolean),
   fetch: Schema.optional(Schema.Boolean),
+  snapshot: Schema.optional(Schema.Boolean),
 })
 })
 export type Options = typeof Options.Type
 export type Options = typeof Options.Type
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
 
 
 const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
 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({
 const Cache = Schema.Struct({
   updatedAt: Schema.Number,
   updatedAt: Schema.Number,
   body: CatalogJson,
   body: CatalogJson,
@@ -605,13 +609,15 @@ export const layer = (options?: Options) =>
           )
           )
         : Effect.succeed(undefined)
         : 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 fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
         const text = yield* fetchApi()
         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
         // Best-effort: a cache-write failure must never kill catalog
         // population. The payload has outgrown some KV backends' per-value
         // population. The payload has outgrown some KV backends' per-value
         // limits (Durable Object SQLite caps values at 2 MB and api.json
         // limits (Durable Object SQLite caps values at 2 MB and api.json

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
packages/core/src/models-dev/snapshot.txt


+ 21 - 6
packages/core/test/models.test.ts

@@ -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* () {
     Effect.gen(function* () {
       const cache = makeCache()
       const cache = makeCache()
       const state = yield* Ref.make(initialState)
       const state = yield* Ref.make(initialState)
@@ -237,7 +250,9 @@ describe("ModelsDev Service", () => {
         cache,
         cache,
         ModelsDev.Service.use((s) => s.get()),
         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)
       const final = yield* Ref.get(state)
       expect(final.calls).toEqual([])
       expect(final.calls).toEqual([])
     }),
     }),
@@ -248,7 +263,7 @@ describe("ModelsDev Service", () => {
       const cache = makeCache()
       const cache = makeCache()
       writeCacheText(cache, "{")
       writeCacheText(cache, "{")
       const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
       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))
       const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
       expect(result).toEqual(fixture2Snapshot)
       expect(result).toEqual(fixture2Snapshot)
       expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
       expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
@@ -263,7 +278,7 @@ describe("ModelsDev Service", () => {
       const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
       const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
       const layer = Layer.fresh(
       const layer = Layer.fresh(
         AppNodeBuilder.build(ModelsDev.node, [
         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))],
           [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
           [KV.node, makeFailingWriteKV(cache)],
           [KV.node, makeFailingWriteKV(cache)],
         ]),
         ]),
@@ -281,7 +296,7 @@ describe("ModelsDev Service", () => {
       const cache = makeCache()
       const cache = makeCache()
       const state = yield* Ref.make(initialState)
       const state = yield* Ref.make(initialState)
       yield* ModelsDev.Service.use((service) => service.get()).pipe(
       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")
       expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
     }),
     }),
@@ -296,7 +311,7 @@ describe("ModelsDev Service", () => {
         return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
         return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
           concurrency: "unbounded",
           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)
       for (const result of results) expect(result).toEqual(fixtureSnapshot)
       expect((yield* Ref.get(state)).calls.length).toBe(1)
       expect((yield* Ref.get(state)).calls.length).toBe(1)
     }),
     }),

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно