Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand All @@ -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)),
Expand All @@ -211,7 +215,7 @@ const layer = Layer.effect(
}),
),
)
return text
return catalog
})

const populate = Effect.gen(function* () {
Expand All @@ -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)

Expand Down
55 changes: 45 additions & 10 deletions packages/core/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
Expand Down Expand Up @@ -159,23 +171,46 @@ 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)
expect(final.calls.length).toBe(1)
}),
)

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)
Expand Down
Loading