From 358a53cb1fd651efd4758a88c1ffdfbc183e6af2 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 22 Aug 2026 21:03:02 -0400 Subject: [PATCH] fix(core): skip models.dev refresh event when the catalog is unchanged (#44282) --- packages/core/src/models-dev.ts | 45 +++++++++----- packages/core/test/models.test.ts | 97 ++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 23 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 948a0001783..0ac247deac7 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -541,6 +541,9 @@ const decodeCatalog = (text: string) => Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record)) 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" @@ -568,6 +571,10 @@ function cacheKey(source: string) { return `models-dev:catalog:${Hash.fast(source)}` } +export function bodyDigest(text: string) { + return new Bun.CryptoHasher("sha256").update(text).digest("hex") +} + export const layer = (options?: Options) => Layer.effect( Service, @@ -600,16 +607,11 @@ export const layer = (options?: Options) => return { catalog: cached.value.body as Record, updatedAt: cached.value.updatedAt, + digest: cached.value.digest, } if (value !== undefined) yield* kv.remove(key) }) - const fresh = Effect.fnUntraced(function* () { - const cached = yield* loadFromCache() - if (!cached) return false - return Date.now() - cached.updatedAt < Duration.toMillis(ttl) - }) - const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { return yield* HttpClientRequest.get(`${source}/api.json`).pipe( HttpClientRequest.setHeader("User-Agent", userAgent), @@ -630,19 +632,23 @@ export const layer = (options?: Options) => // periodic fetch below still refreshes on top. const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot - const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { - const text = yield* fetchApi() - 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 - // passed it in Aug 2026); a boot without a cache hit just refetches. - yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe( + // 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 }), ), ) + }) + + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + const catalog = yield* decodeCatalog(text) + yield* writeCache(text) return catalog }) @@ -672,8 +678,15 @@ export const layer = (options?: Options) => yield* lock .withPermit( Effect.gen(function* () { - if (!force && (yield* fresh())) return - yield* fetchAndWrite() + 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, {}) }), diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 6a2083d51e6..47fc452637e 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "bun:test" import { Money } from "@opencode-ai/schema/money" -import { Effect, Layer, Ref } from "effect" +import { Effect, Fiber, Layer, Ref, Scope, Stream } 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 { Bus } from "@opencode-ai/core/bus" import { KV } from "@opencode-ai/core/kv" import { Model } from "@opencode-ai/core/model" -import { ModelsDev } from "@opencode-ai/core/models-dev" +import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev" import { Provider } from "@opencode-ai/core/provider" import { it } from "./lib/effect" @@ -180,7 +181,7 @@ const buildLayer = (state: Ref.Ref, cache: MockCache, options: Models // and Effect.provide uses a process-global MemoMap by default — without fresh, // every test would reuse the cachedInvalidateWithTTL state from the first run. Layer.fresh( - AppNodeBuilder.build(ModelsDev.node, [ + AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [ [ModelsDev.node, ModelsDev.configured(options)], [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], [KV.node, makeMockKV(cache)], @@ -199,13 +200,16 @@ const makeFailingWriteKV = (cache: MockCache) => const makeCache = (): MockCache => ({ values: new Map() }) const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) => - cache.values.set(cacheKey, { updatedAt, body: text }) + cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text }) const writeCache = (cache: MockCache, data: object, updatedAt?: number) => writeCacheText(cache, JSON.stringify(data), updatedAt) -const provided = (state: Ref.Ref, cache: MockCache, eff: Effect.Effect) => - eff.pipe(Effect.provide(buildLayer(state, cache))) +const provided = ( + state: Ref.Ref, + cache: MockCache, + eff: Effect.Effect, +) => eff.pipe(Effect.provide(buildLayer(state, cache))) const initialState: MockState = { body: JSON.stringify(fixture), @@ -391,7 +395,20 @@ describe("ModelsDev Service", () => { cache, Effect.gen(function* () { const svc = yield* ModelsDev.Service - yield* svc.refresh(false) + const bus = yield* Bus.Service + const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + Effect.flatMap((fiber) => + Effect.gen(function* () { + yield* Effect.yieldNow + yield* svc.refresh(false) + return yield* Fiber.join(fiber) + }), + ), + ) + expect(refreshed.length).toBe(1) return yield* svc.get() }), ) @@ -401,6 +418,72 @@ describe("ModelsDev Service", () => { }), ) + it.live("refresh(false) stays quiet when the fetched body matches the cached digest", () => + Effect.gen(function* () { + const cache = makeCache() + writeCache(cache, fixture, Date.now() - 10 * 60 * 1000) + const seeded = structuredClone(cache.values.get(cacheKey)) + // The server serves a byte-identical body, so the refresh still hits + // the network but must not rewrite the cache or publish Refreshed. + const state = yield* Ref.make(initialState) + yield* provided( + state, + cache, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const bus = yield* Bus.Service + const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + Effect.flatMap((fiber) => + Effect.gen(function* () { + yield* Effect.yieldNow + yield* svc.refresh(false) + return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis")) + }), + ), + ) + expect(event._tag).toBe("None") + }), + ) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(cache.values.get(cacheKey)).toEqual(seeded) + }), + ) + + it.live("refresh(false) republishes once for legacy cache entries without a digest", () => + Effect.gen(function* () { + const cache = makeCache() + cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) }) + const state = yield* Ref.make(initialState) + yield* provided( + state, + cache, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const bus = yield* Bus.Service + const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + Effect.flatMap((fiber) => + Effect.gen(function* () { + yield* Effect.yieldNow + yield* svc.refresh(false) + return yield* Fiber.join(fiber) + }), + ), + ) + expect(refreshed.length).toBe(1) + }), + ) + // The rewritten entry now carries a digest, so later identical bodies stay quiet. + expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) }) + }), + ) + it.live("refresh swallows HTTP errors and leaves cache intact", () => Effect.gen(function* () { const cache = makeCache()