Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline e0a4e8b96c refactor(core): remove redundant model catalog die 2026-08-09 21:25:59 +00:00
Aiden Cline 0b3e0d80d0 fix(core): validate model catalog responses 2026-08-09 21:22:07 +00:00
Aiden Cline 5061a910e6 fix(core): tolerate model catalog fetch failure 2026-08-09 04:18:38 +00:00
2 changed files with 57 additions and 14 deletions
+12 -4
View File
@@ -131,6 +131,8 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider>
const Catalog = Schema.Record(Schema.String, Provider)
export const Event = ModelsDev.Event
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | 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<string, Provider>
}).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<string, Provider>)),
),
)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
+45 -10
View File
@@ -112,6 +112,18 @@ const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.strin
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
const withFetch = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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)