Skip to content

Commit ce08760

Browse files
authored
feat(models): effectify ModelsDev as Service (anomalyco#25434)
1 parent bcb8ac0 commit ce08760

8 files changed

Lines changed: 381 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: 110 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, Option, Schedule, Schema } from "effect"
4+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
55
import { Installation } from "../installation"
66
import { Flag } from "@opencode-ai/core/flag/flag"
7-
import { lazy } from "@/util/lazy"
8-
import { Filesystem } from "@/util/filesystem"
97
import { Flock } from "@opencode-ai/core/util/flock"
108
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
9+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
10+
import { makeRuntime } from "@/effect/run-service"
11+
import { withTransientReadRetry } from "@/util/effect-http-client"
2312

2413
const Cost = Schema.Struct({
2514
input: Schema.Finite,
@@ -101,76 +90,119 @@ 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+
const fresh = Effect.fnUntraced(function* () {
115+
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
116+
if (!stat) return false
117+
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
118+
return Date.now() - mtime < Duration.toMillis(ttl)
119+
})
145120

146-
export async function get() {
147-
const result = await Data()
148-
return result as Record<string, Provider>
149-
}
121+
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
122+
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
123+
HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT),
124+
http.execute,
125+
Effect.flatMap((res) => res.text),
126+
Effect.timeout("10 seconds"),
127+
)
128+
})
150129

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,
130+
const loadFromDisk = fs
131+
.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)
132+
.pipe(
133+
Effect.catch(() => Effect.succeed(undefined)),
134+
Effect.map((v) => v as Record<string, Provider> | undefined),
135+
)
136+
137+
// Bundled at build time; absent in dev — `tryPromise` covers both.
138+
const loadSnapshot = Effect.tryPromise({
139+
// @ts-ignore — generated at build time, may not exist in dev
140+
try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record<string, Provider> | undefined),
141+
catch: () => undefined,
142+
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
143+
144+
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
145+
const text = yield* fetchApi()
146+
yield* fs.writeWithDirs(filepath, text)
147+
return text
162148
})
163-
})
164-
}
165149

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-
}
150+
const populate = Effect.gen(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+
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
157+
const text = yield* Effect.scoped(
158+
Effect.gen(function* () {
159+
yield* Flock.effect(lockKey)
160+
return yield* fetchAndWrite()
161+
}),
162+
)
163+
return JSON.parse(text) as Record<string, Provider>
164+
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
165+
166+
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
167+
168+
const get = (): Effect.Effect<Record<string, Provider>> => cachedGet
169+
170+
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
171+
if (!force && (yield* fresh())) return
172+
yield* Effect.scoped(
173+
Effect.gen(function* () {
174+
yield* Flock.effect(lockKey)
175+
// Re-check under the lock: another process may have refreshed between
176+
// our outer check and lock acquisition.
177+
if (!force && (yield* fresh())) return
178+
yield* fetchAndWrite()
179+
yield* invalidate
180+
}),
181+
).pipe(
182+
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
183+
Effect.ignore,
184+
)
185+
})
186+
187+
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
188+
// Schedule.spaced runs the effect once, then waits between completions.
189+
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
190+
}
191+
192+
return Service.of({ get, refresh })
193+
}),
194+
)
195+
196+
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
197+
Layer.provide(FetchHttpClient.layer),
198+
Layer.provide(AppFileSystem.defaultLayer),
199+
)
200+
201+
// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers).
202+
// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one
203+
// AppRuntime sees — Effect callers and Promise callers operate on the same cache.
204+
const runtime = makeRuntime(Service, defaultLayer)
205+
export const get = () => runtime.runPromise((s) => s.get())
206+
export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force))
175207

176208
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/httpapi/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store"
2323
import { Plugin } from "@/plugin"
2424
import { Project } from "@/project/project"
2525
import { ProviderAuth } from "@/provider/auth"
26+
import { ModelsDev } from "@/provider/models"
2627
import { Provider } from "@/provider/provider"
2728
import { Pty } from "@/pty"
2829
import { Question } from "@/question"
@@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
155156
InstanceBootstrap.defaultLayer,
156157
InstanceStore.defaultLayer,
157158
MCP.defaultLayer,
159+
ModelsDev.defaultLayer,
158160
Permission.defaultLayer,
159161
Plugin.defaultLayer,
160162
Project.defaultLayer,

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)