Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/models-catalog-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@opencode-ai/core": patch
"@opencode-ai/server": patch
---

Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
163 changes: 71 additions & 92 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
Expand All @@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
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 { ModelsDevCache } from "./models-dev/cache.js"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }

export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
Expand Down Expand Up @@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
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,
// Digest of the raw body, persisted so refresh() can skip republishing a
// byte-identical catalog. Optional for entries written before it existed.
digest: Schema.optional(Schema.String),
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"

// Bundled snapshot of https://models.opencode.ai/api.json, committed at
Expand All @@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
return bundledCache
}),
),
)

function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}

export function bodyDigest(text: string) {
return Hash.sha256(text)
}
Expand All @@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const kv = yield* KV.Service
const cache = yield* ModelsDevCache.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
Expand All @@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lock = Semaphore.makeUnsafe(1)

const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
digest: cached.value.digest,
}
if (value !== undefined) yield* kv.remove(key)
})
const state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }

const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
Expand All @@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
)
})

const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.orElseSucceed(() => undefined),
// Persistence only seeds a runtime. Refresh never reloads this seed over
// a catalog that was successfully fetched but could not be saved.
// The service owns initialization so cancelling a reader cannot cancel it.
const initialized = yield* Effect.forkScoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const stored = options?.file
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
: yield* cache.read(source)
if (!stored) return
const data = normalize(yield* decodeCatalog(stored.body))
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
),
)
: Effect.undefined

// The bundled snapshot is the boot-time floor for the catalog; the
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot

// 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
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
})
if (state.data) return
if (options?.snapshot !== false) {
Object.assign(state, yield* bundledSnapshot)
return
}
if (!fetch) state.data = []
}).pipe(Effect.orDie),
)

const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
yield* writeCache(text)
return catalog
const update = Effect.fn("ModelsDev.update")(function* (force = false) {
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
const digest = bodyDigest(text)
if (!force && state.data && state.digest === digest) {
state.checkedAt = Date.now()
return state.data
}
const data = normalize(yield* decodeCatalog(text))
Object.assign(state, { data, digest, checkedAt: Date.now() })
yield* bus.publish(ModelsDev.Event.Refreshed, {})
// Adopt and publish before attempting persistence. A missing or broken
// cache must not prevent live updates, including in filesystem-less runtimes.
if (!options?.file)
yield* cache.write(source, text).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return data
})

const populate = Effect.gen(function* () {
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return bundled
if (!fetch) return []
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)

const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)

const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const get = Effect.fn("ModelsDev.get")(function* () {
yield* Fiber.join(initialized)
if (state.data) return state.data
return yield* lock
.withPermit(
Effect.gen(function* () {
return state.data ?? (yield* update())
}),
)
.pipe(Effect.orDie)
})

const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
yield* lock
.withPermit(
Effect.gen(function* () {
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === bodyDigest(text)) return
yield* decodeCatalog(text)
yield* writeCache(text)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
yield* Fiber.join(initialized)
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
yield* update(force)
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
Effect.orDie,
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
),
)
})

Expand All @@ -710,7 +689,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
})
}

Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/models-dev/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export * as ModelsDevCache from "./cache.js"

import path from "path"
import { Context, Effect, FileSystem, Layer, Option } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"

export interface Entry {
readonly body: string
readonly updatedAt: number
}

export interface Interface {
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
}

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

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")

const read = Effect.fn("ModelsDevCache.read")(
function* (source: string) {
const file = path.join(directory, `${Hash.fast(source)}.json`)
const body = yield* fs.readFileString(file)
const info = yield* fs.stat(file)
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
},
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
)

const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
yield* fs.makeDirectory(directory, { recursive: true })
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
yield* fs.writeFileString(temporary, body)
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
}, Effect.scoped)

return Service.of({ read, write })
}),
)

export const node = makeGlobalNode({
service: Service,
layer,
deps: [LayerNodePlatform.filesystem, Global.node],
})

export const disabledLayer = Layer.succeed(
Service,
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
)
Loading
Loading