diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 31936e75c4..3e197b90e1 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,4 +1,4 @@ -import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect" +import { Cause, 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" @@ -612,7 +612,16 @@ 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(key, { updatedAt: Date.now(), body: 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( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }), + ), + ) return catalog }) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 43368d7d28..230bc767ba 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -185,6 +185,15 @@ const buildLayer = (state: Ref.Ref, cache: MockCache, options: Models ]), ) +// Mirrors production KV backends whose writes die as defects (e.g. Durable +// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError). +const makeFailingWriteKV = (cache: MockCache) => + Layer.mock(KV.Service, { + get: (key) => Effect.sync(() => cache.values.get(key)), + set: () => Effect.die(new Error("Failed query: insert into \"kv\"")), + remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid), + }) + const makeCache = (): MockCache => ({ values: new Map() }) const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) => @@ -248,6 +257,25 @@ describe("ModelsDev Service", () => { }), ) + it.live("get() still populates the catalog when the KV cache write fails", () => + Effect.gen(function* () { + const cache = makeCache() + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const layer = Layer.fresh( + AppNodeBuilder.build(ModelsDev.node, [ + [ModelsDev.node, ModelsDev.configured({ fetch: true })], + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], + [KV.node, makeFailingWriteKV(cache)], + ]), + ) + const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer)) + expect(result).toEqual(fixture2Snapshot) + expect(cache.values.has(cacheKey)).toBe(false) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + }), + ) + it.live("uses the default models URL when the configured URL is empty", () => Effect.gen(function* () { const cache = makeCache()