Skip to content
Merged
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
48 changes: 32 additions & 16 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Global } from "./global"
import { Flag } from "./flag/flag"
Expand Down Expand Up @@ -109,6 +109,10 @@ export const Provider = Schema.Struct({

export type Provider = Schema.Schema.Type<typeof Provider>

const Providers = Schema.Record(Schema.String, Provider)
const decodeProvidersEffect = Schema.decodeUnknownEffect(Providers)
const decodeProviders = Schema.decodeUnknownSync(Providers)

export const Event = ModelsDev.Event

declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
Expand Down Expand Up @@ -141,6 +145,7 @@ const layer = Layer.effect(
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
const populateFetchTimeout = Duration.seconds(2)
const lockKey = `models-dev:${filepath}`

const fresh = Effect.fnUntraced(function* () {
Expand All @@ -160,23 +165,25 @@ const layer = Layer.effect(
})

const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
error._tag === "FileSystemError" &&
error.method === "readJson"
) {
Effect.flatMap((v) => decodeProvidersEffect(v)),
Effect.catch((_error) => {
if (Flag.OPENCODE_MODELS_PATH === undefined) {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
Effect.map((v) => v as Record<string, Provider> | undefined),
)

const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)

const loadCached = Effect.fn("ModelsDev.loadCached")(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return fromDisk
return yield* loadSnapshot
})

const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
Expand All @@ -192,20 +199,29 @@ const layer = Layer.effect(
return text
})

const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return fromDisk
const snapshot = yield* loadSnapshot
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 fetchFromRemote = Effect.gen(function* () {
const text = yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
return yield* fetchAndWrite()
}),
)
return JSON.parse(text) as Record<string, Provider>
return decodeProviders(JSON.parse(text))
})

const populate = Effect.gen(function* () {
const cached = yield* loadCached()
if (cached) return cached
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
return yield* fetchFromRemote.pipe(
Effect.timeout(populateFetchTimeout),
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("Falling back to cached models.dev catalog", { cause })
return (yield* loadCached()) ?? {}
}),
),
)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)

const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
Expand Down
48 changes: 47 additions & 1 deletion packages/core/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Hash } from "@opencode-ai/core/util/hash"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
Expand All @@ -28,6 +28,7 @@ afterAll(() => {
})

const cacheFile = path.join(Global.Path.cache, "models.json")
const lockDir = path.join(Global.Path.state, "locks", `${Hash.fast(`models-dev:${cacheFile}`)}.lock`)

const fixture: Record<string, ModelsDev.Provider> = {
acme: {
Expand Down Expand Up @@ -72,6 +73,7 @@ const fixture2: Record<string, ModelsDev.Provider> = {
interface MockState {
body: string
status: number
stall?: boolean
calls: Array<{ url: string; userAgent: string | null }>
}

Expand All @@ -83,6 +85,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
calls: [...s.calls, { url: request.url, userAgent: request.headers["user-agent"] ?? null }],
}))
const s = yield* Ref.get(state)
if (s.stall) return yield* Effect.never
return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
}),
)
Expand Down Expand Up @@ -114,10 +117,12 @@ const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, Mode

beforeEach(async () => {
await rm(cacheFile, { force: true })
await rm(lockDir, { recursive: true, force: true })
})

afterAll(async () => {
await rm(cacheFile, { force: true })
await rm(lockDir, { recursive: true, force: true })
})

const initialState: MockState = {
Expand Down Expand Up @@ -154,6 +159,47 @@ describe("ModelsDev Service", () => {
}),
)

it.live(
"get() falls back when startup fetch stalls and refresh can later replace it",
Effect.gen(function* () {
const state = yield* Ref.make<MockState>({ ...initialState, stall: true })
const context = yield* Layer.build(buildLayer(state))
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() =>
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const started = Date.now()
const fallback = yield* svc.get()
const elapsed = Date.now() - started

yield* Ref.update(state, (s) => ({
...s,
body: JSON.stringify(fixture2),
status: 200,
stall: false,
}))
yield* svc.refresh(true)
const refreshed = yield* svc.get()
return { elapsed, fallback, refreshed }
}).pipe(Effect.provide(context)),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)

expect(result.elapsed).toBeLessThan(5000)
expect(result.fallback).toEqual({})
expect(result.refreshed).toEqual(fixture2)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(2)
}),
10_000,
)

it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
Effect.gen(function* () {
yield* writeCacheText("{")
Expand Down
Loading