|
1 | 1 | import { Global } from "@opencode-ai/core/global" |
2 | | -import * as Log from "@opencode-ai/core/util/log" |
3 | 2 | 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" |
5 | 6 | import { Installation } from "../installation" |
6 | 7 | import { Flag } from "@opencode-ai/core/flag/flag" |
7 | | -import { lazy } from "@/util/lazy" |
8 | | -import { Filesystem } from "@/util/filesystem" |
9 | 8 | import { Flock } from "@opencode-ai/core/util/flock" |
10 | 9 | 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" |
23 | 12 |
|
24 | 13 | const Cost = Schema.Struct({ |
25 | 14 | input: Schema.Finite, |
@@ -101,76 +90,122 @@ export const Provider = Schema.Struct({ |
101 | 90 |
|
102 | 91 | export type Provider = Schema.Schema.Type<typeof Provider> |
103 | 92 |
|
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> |
106 | 96 | } |
107 | 97 |
|
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") {} |
111 | 99 |
|
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)) |
115 | 105 |
|
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}` |
123 | 113 |
|
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 |
145 | 115 |
|
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 | + }) |
150 | 122 |
|
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 | + ) |
162 | 130 | }) |
163 | | - }) |
164 | | -} |
165 | 131 |
|
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))) |
175 | 210 |
|
176 | 211 | export * as ModelsDev from "./models" |
0 commit comments