Skip to content

Commit ca8fd0a

Browse files
committed
feat(models): effectify ModelsDev as Service
Replaces the Promise-based, module-state-heavy ModelsDev with an Effect-native Service that uses AppFileSystem (fs.stat / fs.readJson / fs.writeWithDirs) and HttpClient (HttpClientRequest + filterStatusOk + withTransientReadRetry) instead of raw fetch + Filesystem.* helpers. Periodic refresh moves from a module-load setInterval to a scoped fork that gets cleaned up with the Service's scope. Effect-context callers yield ModelsDev.Service: - src/provider/provider.ts (yields modelsDevSvc once at layer top) - src/server/routes/instance/httpapi/handlers/provider.ts - src/server/routes/instance/provider.ts - src/cli/cmd/models.ts (drops the Effect.promise wrap) Promise-context legacy callers (cli/cmd/github.ts, cli/cmd/providers.ts) keep using ModelsDev.get() / ModelsDev.refresh(), which now route through a tiny ManagedRuntime + Service wrapper that shares memoMap with AppRuntime — so Effect callers and Promise callers see the same Service instance and cache. What stays Promise-wrapped: - Flock.withLock for cross-process file coordination (in-process Effect semaphores can't coordinate concurrent CLIs writing the same cache) - The dynamic import('./models-snapshot.js') one-time bootstrap Smoke tested: bun run dev models, bun run dev models nonexistent. Provider + httpapi-provider tests pass (78/78). Typecheck clean.
1 parent 4bfd61c commit ca8fd0a

6 files changed

Lines changed: 122 additions & 84 deletions

File tree

packages/opencode/src/cli/cmd/models.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({
2626
}),
2727
handler: Effect.fn("Cli.models")(function* (args) {
2828
if (args.refresh) {
29-
// followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap.
30-
yield* Effect.promise(() => ModelsDev.refresh(true))
29+
yield* ModelsDev.Service.use((s) => s.refresh(true))
3130
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
3231
}
3332

packages/opencode/src/effect/app-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher"
1414
import { Storage } from "@/storage/storage"
1515
import { Snapshot } from "@/snapshot"
1616
import { Plugin } from "@/plugin"
17+
import { ModelsDev } from "@/provider/models"
1718
import { Provider } from "@/provider/provider"
1819
import { ProviderAuth } from "@/provider/auth"
1920
import { Agent } from "@/agent/agent"
@@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll(
6667
Storage.defaultLayer,
6768
Snapshot.defaultLayer,
6869
Plugin.defaultLayer,
70+
ModelsDev.defaultLayer,
6971
Provider.defaultLayer,
7072
ProviderAuth.defaultLayer,
7173
Agent.defaultLayer,
Lines changed: 113 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,14 @@
11
import { Global } from "@opencode-ai/core/global"
2-
import * as Log from "@opencode-ai/core/util/log"
32
import path from "path"
4-
import { Schema } from "effect"
3+
import { Context, Duration, Effect, Layer, ManagedRuntime, Option, Schedule, Schema } from "effect"
4+
import { memoMap } from "@opencode-ai/core/effect/memo-map"
5+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
56
import { Installation } from "../installation"
67
import { Flag } from "@opencode-ai/core/flag/flag"
7-
import { lazy } from "@/util/lazy"
8-
import { Filesystem } from "@/util/filesystem"
98
import { Flock } from "@opencode-ai/core/util/flock"
109
import { Hash } from "@opencode-ai/core/util/hash"
11-
12-
// Try to import bundled snapshot (generated at build time)
13-
// Falls back to undefined in dev mode when snapshot doesn't exist
14-
/* @ts-ignore */
15-
16-
const log = Log.create({ service: "models.dev" })
17-
const source = url()
18-
const filepath = path.join(
19-
Global.Path.cache,
20-
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
21-
)
22-
const ttl = 5 * 60 * 1000
10+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
11+
import { withTransientReadRetry } from "@/util/effect-http-client"
2312

2413
const Cost = Schema.Struct({
2514
input: Schema.Finite,
@@ -101,76 +90,122 @@ export const Provider = Schema.Struct({
10190

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

104-
function url() {
105-
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
93+
export interface Interface {
94+
readonly get: () => Effect.Effect<Record<string, Provider>>
95+
readonly refresh: (force?: boolean) => Effect.Effect<void>
10696
}
10797

108-
function fresh() {
109-
return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl
110-
}
98+
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
11199

112-
function skip(force: boolean) {
113-
return !force && fresh()
114-
}
100+
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClient.HttpClient> = Layer.effect(
101+
Service,
102+
Effect.gen(function* () {
103+
const fs = yield* AppFileSystem.Service
104+
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
115105

116-
const fetchApi = async () => {
117-
const result = await fetch(`${url()}/api.json`, {
118-
headers: { "User-Agent": Installation.USER_AGENT },
119-
signal: AbortSignal.timeout(10000),
120-
})
121-
return { ok: result.ok, text: await result.text() }
122-
}
106+
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
107+
const filepath = path.join(
108+
Global.Path.cache,
109+
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
110+
)
111+
const ttl = Duration.minutes(5)
112+
const lockKey = `models-dev:${filepath}`
123113

124-
export const Data = lazy(async () => {
125-
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
126-
if (result) return result
127-
// @ts-ignore
128-
const snapshot = await import("./models-snapshot.js")
129-
.then((m) => m.snapshot as Record<string, unknown>)
130-
.catch(() => undefined)
131-
if (snapshot) return snapshot
132-
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
133-
return Flock.withLock(`models-dev:${filepath}`, async () => {
134-
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
135-
if (result) return result
136-
const result2 = await fetchApi()
137-
if (result2.ok) {
138-
await Filesystem.write(filepath, result2.text).catch((e) => {
139-
log.error("Failed to write models cache", { error: e })
140-
})
141-
}
142-
return JSON.parse(result2.text)
143-
})
144-
})
114+
let cached: Record<string, Provider> | undefined
145115

146-
export async function get() {
147-
const result = await Data()
148-
return result as Record<string, Provider>
149-
}
116+
const fresh = Effect.fnUntraced(function* () {
117+
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
118+
if (!stat?.mtime) return false
119+
const mtime = Option.isOption(stat.mtime) ? Number(Option.getOrElse(stat.mtime, () => new Date(0)).getTime()) : 0
120+
return Date.now() - mtime < Duration.toMillis(ttl)
121+
})
150122

151-
export async function refresh(force = false) {
152-
if (skip(force)) return Data.reset()
153-
await Flock.withLock(`models-dev:${filepath}`, async () => {
154-
if (skip(force)) return Data.reset()
155-
const result = await fetchApi()
156-
if (!result.ok) return
157-
await Filesystem.write(filepath, result.text)
158-
Data.reset()
159-
}).catch((e) => {
160-
log.error("Failed to fetch models.dev", {
161-
error: e,
123+
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
124+
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
125+
HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT),
126+
http.execute,
127+
Effect.flatMap((res) => res.text),
128+
Effect.timeout("10 seconds"),
129+
)
162130
})
163-
})
164-
}
165131

166-
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
167-
void refresh()
168-
setInterval(
169-
async () => {
170-
await refresh()
171-
},
172-
60 * 1000 * 60,
173-
).unref()
174-
}
132+
const loadFromDisk = Effect.fnUntraced(function* () {
133+
return yield* fs
134+
.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)
135+
.pipe(Effect.catch(() => Effect.succeed(undefined))) as Effect.Effect<
136+
Record<string, Provider> | undefined
137+
>
138+
})
139+
140+
const loadSnapshot = Effect.promise(async () => {
141+
try {
142+
// @ts-ignore — generated at build time, may not exist in dev
143+
const m = await import("./models-snapshot.js")
144+
return m.snapshot as Record<string, Provider> | undefined
145+
} catch {
146+
return undefined
147+
}
148+
})
149+
150+
const populate = Effect.fn("ModelsDev.populate")(function* () {
151+
const fromDisk = yield* loadFromDisk()
152+
if (fromDisk) return fromDisk
153+
const snapshot = yield* loadSnapshot
154+
if (snapshot) return snapshot
155+
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
156+
157+
// Cross-process file lock — Flock is the right primitive (in-process
158+
// semaphores can't coordinate concurrent opencode CLIs writing the same cache).
159+
return yield* Effect.promise(() =>
160+
Flock.withLock(lockKey, async () => {
161+
const text = await Effect.runPromise(fetchApi())
162+
await Effect.runPromise(fs.writeWithDirs(filepath, text))
163+
return JSON.parse(text) as Record<string, Provider>
164+
}),
165+
)
166+
})
167+
168+
const get = Effect.fn("ModelsDev.get")(function* () {
169+
if (cached) return cached
170+
cached = yield* populate()
171+
return cached
172+
})
173+
174+
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
175+
if (!force && (yield* fresh())) {
176+
cached = undefined
177+
return
178+
}
179+
yield* Effect.promise(() =>
180+
Flock.withLock(lockKey, async () => {
181+
const text = await Effect.runPromise(fetchApi())
182+
await Effect.runPromise(fs.writeWithDirs(filepath, text))
183+
}),
184+
).pipe(
185+
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
186+
Effect.ignore,
187+
)
188+
cached = undefined
189+
})
190+
191+
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
192+
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")), Effect.ignore))
193+
}
194+
195+
return Service.of({ get, refresh })
196+
}),
197+
)
198+
199+
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
200+
Layer.provide(FetchHttpClient.layer),
201+
Layer.provide(AppFileSystem.defaultLayer),
202+
)
203+
204+
// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers).
205+
// Uses the shared memoMap so this runtime's Service instance is shared with AppRuntime
206+
// — Effect callers that yield ModelsDev.Service see the same cache.
207+
const promiseRuntime = ManagedRuntime.make(defaultLayer, { memoMap })
208+
export const get = () => promiseRuntime.runPromise(Service.use((s) => s.get()))
209+
export const refresh = (force = false) => promiseRuntime.runPromise(Service.use((s) => s.refresh(force)))
175210

176211
export * as ModelsDev from "./models"

packages/opencode/src/provider/provider.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
10741074
const layer: Layer.Layer<
10751075
Service,
10761076
never,
1077-
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
1077+
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service
10781078
> = Layer.effect(
10791079
Service,
10801080
Effect.gen(function* () {
@@ -1083,13 +1083,14 @@ const layer: Layer.Layer<
10831083
const auth = yield* Auth.Service
10841084
const env = yield* Env.Service
10851085
const plugin = yield* Plugin.Service
1086+
const modelsDevSvc = yield* ModelsDev.Service
10861087

10871088
const state = yield* InstanceState.make<State>(() =>
10881089
Effect.gen(function* () {
10891090
using _ = log.time("state")
10901091
const bridge = yield* EffectBridge.make()
10911092
const cfg = yield* config.get()
1092-
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
1093+
const modelsDev = yield* modelsDevSvc.get()
10931094
const database = mapValues(modelsDev, fromModelsDevProvider)
10941095

10951096
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
@@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() =>
17221723
Layer.provide(Config.defaultLayer),
17231724
Layer.provide(Auth.defaultLayer),
17241725
Layer.provide(Plugin.defaultLayer),
1726+
Layer.provide(ModelsDev.defaultLayer),
17251727
),
17261728
)
17271729

packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
1717

1818
const list = Effect.fn("ProviderHttpApi.list")(function* () {
1919
const config = yield* cfg.get()
20-
const all = yield* Effect.promise(() => ModelsDev.get())
20+
const all = yield* ModelsDev.Service.use((s) => s.get())
2121
const disabled = new Set(config.disabled_providers ?? [])
2222
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
2323
const filtered: Record<string, (typeof all)[string]> = {}

packages/opencode/src/server/routes/instance/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() =>
3636
const svc = yield* Provider.Service
3737
const cfg = yield* Config.Service
3838
const config = yield* cfg.get()
39-
const all = yield* Effect.promise(() => ModelsDev.get())
39+
const all = yield* ModelsDev.Service.use((s) => s.get())
4040
const disabled = new Set(config.disabled_providers ?? [])
4141
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
4242
const filtered: Record<string, (typeof all)[string]> = {}

0 commit comments

Comments
 (0)