diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index e577935f067a..5124e00fa160 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -131,6 +131,8 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type +const Catalog = Schema.Record(Schema.String, Provider) + export const Event = ModelsDev.Event declare const OPENCODE_MODELS_DEV: Record | undefined @@ -182,6 +184,7 @@ const layer = Layer.effect( }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Catalog)), Effect.catch((error) => { if ( Flag.OPENCODE_MODELS_PATH === undefined && @@ -201,6 +204,7 @@ const layer = Layer.effect( const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() + const catalog = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Catalog))(text) const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp` yield* fs.writeWithDirs(tempfile, text).pipe( Effect.andThen(fs.rename(tempfile, filepath)), @@ -211,7 +215,7 @@ const layer = Layer.effect( }), ), ) - return text + return catalog }) const populate = Effect.gen(function* () { @@ -221,14 +225,18 @@ const layer = Layer.effect( if (snapshot) return snapshot if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} // Flock is cross-process: concurrent opencode CLIs can race on this cache file. - const text = yield* Effect.scoped( + return yield* Effect.scoped( Effect.gen(function* () { yield* Flock.effect(lockKey) return yield* fetchAndWrite() }), ) - return JSON.parse(text) as Record - }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + }).pipe( + Effect.withSpan("ModelsDev.populate"), + Effect.catch((error) => + Effect.logError("Failed to fetch models.dev", { error }).pipe(Effect.as({} as Record)), + ), + ) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8cda..74975fe670f6 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -112,6 +112,18 @@ const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.strin const provided = (state: Ref.Ref, eff: Effect.Effect) => eff.pipe(Effect.provide(buildLayer(state))) +const withFetch = (effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + Flag.OPENCODE_DISABLE_MODELS_FETCH = false + }), + () => effect, + () => + Effect.sync(() => { + Flag.OPENCODE_DISABLE_MODELS_FETCH = true + }), + ) + beforeEach(async () => { await rm(cacheFile, { force: true }) }) @@ -159,16 +171,7 @@ describe("ModelsDev Service", () => { yield* writeCacheText("{") const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const context = yield* Layer.build(buildLayer(state)) - const result = yield* Effect.acquireUseRelease( - Effect.sync(() => { - Flag.OPENCODE_DISABLE_MODELS_FETCH = false - }), - () => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)), - () => - Effect.sync(() => { - Flag.OPENCODE_DISABLE_MODELS_FETCH = true - }), - ) + const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))) expect(result).toEqual(fixture2) expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2)) const final = yield* Ref.get(state) @@ -176,6 +179,38 @@ describe("ModelsDev Service", () => { }), ) + it.live("get() returns an empty catalog when the initial fetch fails", () => + Effect.gen(function* () { + const state = yield* Ref.make({ ...initialState, status: 503 }) + const context = yield* Layer.build(buildLayer(state)) + const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))) + expect(result).toEqual({}) + expect((yield* Ref.get(state)).calls.length).toBe(3) + }), + ) + + it.live("get() returns an empty catalog when the response is malformed JSON", () => + Effect.gen(function* () { + const state = yield* Ref.make({ ...initialState, body: "{" }) + const context = yield* Layer.build(buildLayer(state)) + const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))) + expect(result).toEqual({}) + expect((yield* Ref.get(state)).calls.length).toBe(1) + expect(yield* Effect.promise(() => Bun.file(cacheFile).exists())).toBe(false) + }), + ) + + it.live("get() returns an empty catalog when the response has an invalid shape", () => + Effect.gen(function* () { + const state = yield* Ref.make({ ...initialState, body: JSON.stringify({ acme: {} }) }) + const context = yield* Layer.build(buildLayer(state)) + const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))) + expect(result).toEqual({}) + expect((yield* Ref.get(state)).calls.length).toBe(1) + expect(yield* Effect.promise(() => Bun.file(cacheFile).exists())).toBe(false) + }), + ) + it.live("get() is single-flight under concurrent calls", () => Effect.gen(function* () { yield* writeCache(fixture)