From 39168663a4338948ef897037bbe5afa42ec16a42 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 22:22:19 -0400 Subject: [PATCH 1/2] refactor(core): store the models.dev catalog cache in KV --- packages/core/src/models-dev.ts | 118 ++++++++++++++++-------------- packages/core/test/models.test.ts | 109 +++++++++++++++------------ 2 files changed, 126 insertions(+), 101 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index b7a41ff111bf..6256234f7aa1 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,11 +1,8 @@ -import path from "path" -import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, 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" import { App } from "./app" -import { Global } from "@opencode-ai/util/global" -import { Flock } from "@opencode-ai/util/flock" import { Hash } from "@opencode-ai/util/hash" import { FSUtil } from "@opencode-ai/util/fs-util" import { Bus } from "./bus" @@ -13,6 +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" import { Provider } from "./provider" +import { KV } from "./kv" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -537,6 +535,12 @@ export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/ModelsDev") {} +const Cache = Schema.Struct({ + updatedAt: Schema.Number, + body: Schema.String, +}) +const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) + export const layer = (options?: Options) => Layer.effect( Service, @@ -544,7 +548,7 @@ export const layer = (options?: Options) => const fs = yield* FSUtil.Service const bus = yield* Bus.Service const app = yield* App.Metadata - const global = yield* Global.Service + const kv = yield* KV.Service const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -558,18 +562,32 @@ export const layer = (options?: Options) => const source = options?.url || "https://models.opencode.ai" const fetch = options?.fetch ?? true const userAgent = App.useragent(app) - const filepath = path.join( - global.cache, - source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`, - ) + const cacheKey = + source === "https://models.opencode.ai" ? "models-dev:catalog" : `models-dev:catalog:${Hash.fast(source)}` const ttl = Duration.minutes(5) - const lockKey = `models-dev:${filepath}` + const lock = Semaphore.makeUnsafe(1) + + const loadFromCache = Effect.fnUntraced(function* () { + const value = yield* kv.get(cacheKey) + if (!Schema.is(Cache)(value)) { + if (value !== undefined) yield* kv.remove(cacheKey) + return + } + const catalog = Schema.decodeUnknownOption(CatalogJson)(value.body) + if (Option.isNone(catalog)) { + yield* kv.remove(cacheKey) + return + } + return { + catalog: catalog.value as Record, + updatedAt: value.updatedAt, + } + }) const fresh = Effect.fnUntraced(function* () { - const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!stat) return false - const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() - return Date.now() - mtime < Duration.toMillis(ttl) + const cached = yield* loadFromCache() + if (!cached) return false + return Date.now() - cached.updatedAt < Duration.toMillis(ttl) }) const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { @@ -581,15 +599,12 @@ export const layer = (options?: Options) => ) }) - const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe( - Effect.map((input) => input as Record), - Effect.catch((error) => { - if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") { - return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined)) - } - return Effect.succeed(undefined) - }), - ) + const loadFromFile = options?.file + ? fs.readJson(options.file).pipe( + Effect.map((input) => input as Record), + Effect.catch(() => Effect.succeed(undefined)), + ) + : Effect.succeed(undefined) const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, @@ -597,33 +612,27 @@ export const layer = (options?: Options) => const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() - const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp` - yield* fs.writeWithDirs(tempfile, text).pipe( - Effect.andThen(fs.rename(tempfile, filepath)), - Effect.catch((error) => - Effect.gen(function* () { - yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore) - return yield* Effect.fail(error) - }), - ), - ) - return text + const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record + yield* kv.set(cacheKey, { updatedAt: Date.now(), body: text }) + return catalog }) const populate = Effect.gen(function* () { - const fromDisk = yield* loadFromDisk - if (fromDisk) return normalize(fromDisk) + 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 normalize(bundled) if (!fetch) return [] - // Flock is cross-process: concurrent opencode CLIs can race on this cache file. - const text = yield* Effect.scoped( + const catalog = yield* lock.withPermit( Effect.gen(function* () { - yield* Flock.effect(lockKey) + const stored = options?.file ? undefined : yield* loadFromCache() + if (stored) return stored.catalog return yield* fetchAndWrite() }), ) - return normalize(JSON.parse(text) as Record) + return normalize(catalog) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) @@ -632,20 +641,19 @@ export const layer = (options?: Options) => const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { if (!force && (yield* fresh())) return - yield* Effect.scoped( - Effect.gen(function* () { - yield* Flock.effect(lockKey) - // Re-check under the lock: another process may have refreshed between - // our outer check and lock acquisition. - if (!force && (yield* fresh())) return - yield* fetchAndWrite() - yield* invalidate - yield* bus.publish(ModelsDev.Event.Refreshed, {}) - }), - ).pipe( - Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), - Effect.ignore, - ) + yield* lock + .withPermit( + Effect.gen(function* () { + if (!force && (yield* fresh())) return + yield* fetchAndWrite() + yield* invalidate + yield* bus.publish(ModelsDev.Event.Refreshed, {}) + }), + ) + .pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), + Effect.ignore, + ) }) if (fetch && !process.argv.includes("--get-yargs-completions")) { @@ -661,7 +669,7 @@ export function configured(options?: Options) { return makeGlobalNode({ service: Service, layer: layer(options), - deps: [FSUtil.node, Bus.node, App.node, Global.node, httpClient], + deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient], }) } diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 326e653abfcd..895a9aa58694 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,19 +1,17 @@ -import { describe, expect, beforeEach, afterAll, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Global } from "@opencode-ai/util/global" +import { KV } from "@opencode-ai/core/kv" import { Model } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Provider } from "@opencode-ai/core/provider" import { it } from "./lib/effect" -import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" -import path from "path" -const cacheFile = path.join(Global.Path.cache, "models.json") +const cacheKey = "models-dev:catalog" test("normalizes permissive interleaved values to compatibility", () => { expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" }) @@ -164,7 +162,21 @@ const makeMockClient = (state: Ref.Ref) => }), ) -const buildLayer = (state: Ref.Ref, options: ModelsDev.Options = { fetch: false }) => +interface MockCache { + readonly values: Map +} + +const makeMockKV = (cache: MockCache) => + Layer.succeed( + KV.Service, + KV.Service.of({ + get: (key) => Effect.sync(() => cache.values.get(key)), + set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid), + remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid), + }), + ) + +const buildLayer = (state: Ref.Ref, cache: MockCache, options: ModelsDev.Options = { fetch: false }) => // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant, // and Effect.provide uses a process-global MemoMap by default — without fresh, // every test would reuse the cachedInvalidateWithTTL state from the first run. @@ -172,31 +184,20 @@ const buildLayer = (state: Ref.Ref, options: ModelsDev.Options = { fe AppNodeBuilder.build(ModelsDev.node, [ [ModelsDev.node, ModelsDev.configured(options)], [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], + [KV.node, makeMockKV(cache)], ]), ) -const writeCacheText = (text: string, mtimeMs?: number) => - Effect.promise(async () => { - await mkdir(Global.Path.cache, { recursive: true }) - await writeFile(cacheFile, text) - if (mtimeMs !== undefined) { - const t = mtimeMs / 1000 - await utimes(cacheFile, t, t) - } - }) - -const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs) +const makeCache = (): MockCache => ({ values: new Map() }) -const provided = (state: Ref.Ref, eff: Effect.Effect) => - eff.pipe(Effect.provide(buildLayer(state))) +const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) => + cache.values.set(cacheKey, { updatedAt, body: text }) -beforeEach(async () => { - await rm(cacheFile, { force: true }) -}) +const writeCache = (cache: MockCache, data: object, updatedAt?: number) => + writeCacheText(cache, JSON.stringify(data), updatedAt) -afterAll(async () => { - await rm(cacheFile, { force: true }) -}) +const provided = (state: Ref.Ref, cache: MockCache, eff: Effect.Effect) => + eff.pipe(Effect.provide(buildLayer(state, cache))) const initialState: MockState = { body: JSON.stringify(fixture), @@ -205,12 +206,14 @@ const initialState: MockState = { } describe("ModelsDev Service", () => { - it.live("get() returns normalized snapshots from disk when cache file exists", () => + it.live("get() returns normalized snapshots from KV when a cache entry exists", () => Effect.gen(function* () { - yield* writeCache(fixture) + const cache = makeCache() + writeCache(cache, fixture) const state = yield* Ref.make(initialState) const result = yield* provided( state, + cache, ModelsDev.Service.use((s) => s.get()), ) expect(result).toEqual(fixtureSnapshot) @@ -219,11 +222,13 @@ describe("ModelsDev Service", () => { }), ) - it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () => + it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () => Effect.gen(function* () { + const cache = makeCache() const state = yield* Ref.make(initialState) const result = yield* provided( state, + cache, ModelsDev.Service.use((s) => s.get()), ) expect(result).toEqual([]) @@ -232,14 +237,15 @@ describe("ModelsDev Service", () => { }), ) - it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () => + it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () => Effect.gen(function* () { - yield* writeCacheText("{") + const cache = makeCache() + writeCacheText(cache, "{") const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) - const context = yield* Layer.build(buildLayer(state, { fetch: true })) + const context = yield* Layer.build(buildLayer(state, cache, { fetch: true })) const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)) expect(result).toEqual(fixture2Snapshot) - expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2)) + expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) }) const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) }), @@ -247,9 +253,10 @@ describe("ModelsDev Service", () => { it.live("uses the default models URL when the configured URL is empty", () => Effect.gen(function* () { + const cache = makeCache() const state = yield* Ref.make(initialState) yield* ModelsDev.Service.use((service) => service.get()).pipe( - Effect.provide(buildLayer(state, { url: "", fetch: true })), + Effect.provide(buildLayer(state, cache, { url: "", fetch: true })), ) expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json") }), @@ -257,10 +264,12 @@ describe("ModelsDev Service", () => { it.live("get() is single-flight under concurrent calls", () => Effect.gen(function* () { - yield* writeCache(fixture) + const cache = makeCache() + writeCache(cache, fixture) const state = yield* Ref.make(initialState) const results = yield* provided( state, + cache, Effect.gen(function* () { const svc = yield* ModelsDev.Service return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { @@ -272,17 +281,18 @@ describe("ModelsDev Service", () => { }), ) - it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => + it.live("get() caches across calls (later KV writes are ignored until invalidate)", () => Effect.gen(function* () { - yield* writeCache(fixture) + const cache = makeCache() + writeCache(cache, fixture) const state = yield* Ref.make(initialState) const first = yield* provided( state, + cache, Effect.gen(function* () { const svc = yield* ModelsDev.Service const a = yield* svc.get() - // mutate disk between calls — cache should mask the change - yield* writeCache(fixture2) + writeCache(cache, fixture2) const b = yield* svc.get() return { a, b } }), @@ -294,10 +304,12 @@ describe("ModelsDev Service", () => { it.live("refresh(true) fetches via HttpClient and updates the cache", () => Effect.gen(function* () { - yield* writeCache(fixture) + const cache = makeCache() + writeCache(cache, fixture) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const result = yield* provided( state, + cache, Effect.gen(function* () { const svc = yield* ModelsDev.Service const before = yield* svc.get() @@ -308,6 +320,7 @@ describe("ModelsDev Service", () => { ) expect(result.before).toEqual(fixtureSnapshot) expect(result.after).toEqual(fixture2Snapshot) + expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) }) const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) expect(final.calls[0].url).toContain("/api.json") @@ -315,13 +328,14 @@ describe("ModelsDev Service", () => { }), ) - it.live("refresh(false) skips fetch when on-disk file is fresh", () => + it.live("refresh(false) skips fetch when the KV entry is fresh", () => Effect.gen(function* () { - // Fresh: mtime within the 5-minute TTL. - yield* writeCache(fixture, Date.now() - 1000) + const cache = makeCache() + writeCache(cache, fixture, Date.now() - 1000) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) yield* provided( state, + cache, ModelsDev.Service.use((s) => s.refresh(false)), ) const final = yield* Ref.get(state) @@ -329,13 +343,14 @@ describe("ModelsDev Service", () => { }), ) - it.live("refresh(false) fetches when on-disk file is stale", () => + it.live("refresh(false) fetches when the KV entry is stale", () => Effect.gen(function* () { - // Stale: mtime 10 minutes ago, beyond the 5-minute TTL. - yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) + const cache = makeCache() + writeCache(cache, fixture, Date.now() - 10 * 60 * 1000) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const after = yield* provided( state, + cache, Effect.gen(function* () { const svc = yield* ModelsDev.Service yield* svc.refresh(false) @@ -350,10 +365,12 @@ describe("ModelsDev Service", () => { it.live("refresh swallows HTTP errors and leaves cache intact", () => Effect.gen(function* () { - yield* writeCache(fixture) + const cache = makeCache() + writeCache(cache, fixture) const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) const result = yield* provided( state, + cache, Effect.gen(function* () { const svc = yield* ModelsDev.Service yield* svc.refresh(true) From cf0cf928d8d741d6ca48be0ae38eb91a1e603888 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 22:31:03 -0400 Subject: [PATCH 2/2] refactor(core): simplify models.dev KV cache --- packages/core/src/models-dev.ts | 40 +++++++++++++++---------------- packages/core/test/models.test.ts | 31 ++++++++++-------------- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 6256234f7aa1..31936e75c41f 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -535,11 +535,17 @@ export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/ModelsDev") {} +const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) const Cache = Schema.Struct({ updatedAt: Schema.Number, - body: Schema.String, + body: CatalogJson, }) -const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) +const defaultSource = "https://models.opencode.ai" + +function cacheKey(source: string) { + if (source === defaultSource) return "models-dev:catalog" + return `models-dev:catalog:${Hash.fast(source)}` +} export const layer = (options?: Options) => Layer.effect( @@ -559,29 +565,22 @@ export const layer = (options?: Options) => ), ) - const source = options?.url || "https://models.opencode.ai" + const source = options?.url || defaultSource const fetch = options?.fetch ?? true const userAgent = App.useragent(app) - const cacheKey = - source === "https://models.opencode.ai" ? "models-dev:catalog" : `models-dev:catalog:${Hash.fast(source)}` + const key = cacheKey(source) const ttl = Duration.minutes(5) const lock = Semaphore.makeUnsafe(1) const loadFromCache = Effect.fnUntraced(function* () { - const value = yield* kv.get(cacheKey) - if (!Schema.is(Cache)(value)) { - if (value !== undefined) yield* kv.remove(cacheKey) - return - } - const catalog = Schema.decodeUnknownOption(CatalogJson)(value.body) - if (Option.isNone(catalog)) { - yield* kv.remove(cacheKey) - return - } - return { - catalog: catalog.value as Record, - updatedAt: value.updatedAt, - } + const value = yield* kv.get(key) + const cached = Schema.decodeUnknownOption(Cache)(value) + if (Option.isSome(cached)) + return { + catalog: cached.value.body as Record, + updatedAt: cached.value.updatedAt, + } + if (value !== undefined) yield* kv.remove(key) }) const fresh = Effect.fnUntraced(function* () { @@ -613,7 +612,7 @@ export const layer = (options?: Options) => const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record - yield* kv.set(cacheKey, { updatedAt: Date.now(), body: text }) + yield* kv.set(key, { updatedAt: Date.now(), body: text }) return catalog }) @@ -640,7 +639,6 @@ export const layer = (options?: Options) => const get = (): Effect.Effect => cachedGet const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { - if (!force && (yield* fresh())) return yield* lock .withPermit( Effect.gen(function* () { diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 895a9aa58694..43368d7d2852 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -167,14 +167,11 @@ interface MockCache { } const makeMockKV = (cache: MockCache) => - Layer.succeed( - KV.Service, - KV.Service.of({ - get: (key) => Effect.sync(() => cache.values.get(key)), - set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid), - remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid), - }), - ) + Layer.mock(KV.Service, { + get: (key) => Effect.sync(() => cache.values.get(key)), + set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid), + remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid), + }) const buildLayer = (state: Ref.Ref, cache: MockCache, options: ModelsDev.Options = { fetch: false }) => // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant, @@ -265,19 +262,15 @@ describe("ModelsDev Service", () => { it.live("get() is single-flight under concurrent calls", () => Effect.gen(function* () { const cache = makeCache() - writeCache(cache, fixture) const state = yield* Ref.make(initialState) - const results = yield* provided( - state, - cache, - Effect.gen(function* () { - const svc = yield* ModelsDev.Service - return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { - concurrency: "unbounded", - }) - }), - ) + const results = yield* Effect.gen(function* () { + const svc = yield* 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 }))) for (const result of results) expect(result).toEqual(fixtureSnapshot) + expect((yield* Ref.get(state)).calls.length).toBe(1) }), )