From d5c3663ba3c0d62cf373bb41c535b07909f50ba6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:19:58 -0400 Subject: [PATCH 1/6] feat(models): effectify ModelsDev as Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/opencode/src/cli/cmd/models.ts | 3 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/provider/models.ts | 191 +++++++++++------- packages/opencode/src/provider/provider.ts | 6 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../src/server/routes/instance/provider.ts | 2 +- 6 files changed, 122 insertions(+), 84 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index cfbb959e7af0..183b1816d293 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({ }), handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - // followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap. - yield* Effect.promise(() => ModelsDev.refresh(true)) + yield* ModelsDev.Service.use((s) => s.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 97cd2f629e42..bbf1f4f8de63 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" @@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll( Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, + ModelsDev.defaultLayer, Provider.defaultLayer, ProviderAuth.defaultLayer, Agent.defaultLayer, diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 170fe516c97c..13c06c8bec3e 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,25 +1,14 @@ import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import path from "path" -import { Schema } from "effect" +import { Context, Duration, Effect, Layer, ManagedRuntime, Option, Schedule, Schema } from "effect" +import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Installation } from "../installation" import { Flag } from "@opencode-ai/core/flag/flag" -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" - -// Try to import bundled snapshot (generated at build time) -// Falls back to undefined in dev mode when snapshot doesn't exist -/* @ts-ignore */ - -const log = Log.create({ service: "models.dev" }) -const source = url() -const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, -) -const ttl = 5 * 60 * 1000 +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ input: Schema.Finite, @@ -101,76 +90,122 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type -function url() { - return Flag.OPENCODE_MODELS_URL || "https://models.dev" +export interface Interface { + readonly get: () => Effect.Effect> + readonly refresh: (force?: boolean) => Effect.Effect } -function fresh() { - return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl -} +export class Service extends Context.Service()("@opencode/ModelsDev") {} -function skip(force: boolean) { - return !force && fresh() -} +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) -const fetchApi = async () => { - const result = await fetch(`${url()}/api.json`, { - headers: { "User-Agent": Installation.USER_AGENT }, - signal: AbortSignal.timeout(10000), - }) - return { ok: result.ok, text: await result.text() } -} + const source = Flag.OPENCODE_MODELS_URL || "https://models.dev" + const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + ) + const ttl = Duration.minutes(5) + const lockKey = `models-dev:${filepath}` -export const Data = lazy(async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - // @ts-ignore - const snapshot = await import("./models-snapshot.js") - .then((m) => m.snapshot as Record) - .catch(() => undefined) - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - const result2 = await fetchApi() - if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) - } - return JSON.parse(result2.text) - }) -}) + let cached: Record | undefined -export async function get() { - const result = await Data() - return result as Record -} + const fresh = Effect.fnUntraced(function* () { + const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat?.mtime) return false + const mtime = Option.isOption(stat.mtime) ? Number(Option.getOrElse(stat.mtime, () => new Date(0)).getTime()) : 0 + return Date.now() - mtime < Duration.toMillis(ttl) + }) -export async function refresh(force = false) { - if (skip(force)) return Data.reset() - await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return Data.reset() - const result = await fetchApi() - if (!result.ok) return - await Filesystem.write(filepath, result.text) - Data.reset() - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, + const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { + return yield* HttpClientRequest.get(`${source}/api.json`).pipe( + HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT), + http.execute, + Effect.flatMap((res) => res.text), + Effect.timeout("10 seconds"), + ) }) - }) -} -if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - void refresh() - setInterval( - async () => { - await refresh() - }, - 60 * 1000 * 60, - ).unref() -} + const loadFromDisk = Effect.fnUntraced(function* () { + return yield* fs + .readJson(Flag.OPENCODE_MODELS_PATH ?? filepath) + .pipe(Effect.catch(() => Effect.succeed(undefined))) as Effect.Effect< + Record | undefined + > + }) + + const loadSnapshot = Effect.promise(async () => { + try { + // @ts-ignore — generated at build time, may not exist in dev + const m = await import("./models-snapshot.js") + return m.snapshot as Record | undefined + } catch { + return undefined + } + }) + + const populate = Effect.fn("ModelsDev.populate")(function* () { + const fromDisk = yield* loadFromDisk() + if (fromDisk) return fromDisk + const snapshot = yield* loadSnapshot + if (snapshot) return snapshot + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + + // Cross-process file lock — Flock is the right primitive (in-process + // semaphores can't coordinate concurrent opencode CLIs writing the same cache). + return yield* Effect.promise(() => + Flock.withLock(lockKey, async () => { + const text = await Effect.runPromise(fetchApi()) + await Effect.runPromise(fs.writeWithDirs(filepath, text)) + return JSON.parse(text) as Record + }), + ) + }) + + const get = Effect.fn("ModelsDev.get")(function* () { + if (cached) return cached + cached = yield* populate() + return cached + }) + + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + if (!force && (yield* fresh())) { + cached = undefined + return + } + yield* Effect.promise(() => + Flock.withLock(lockKey, async () => { + const text = await Effect.runPromise(fetchApi()) + await Effect.runPromise(fs.writeWithDirs(filepath, text)) + }), + ).pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), + Effect.ignore, + ) + cached = undefined + }) + + if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")), Effect.ignore)) + } + + return Service.of({ get, refresh }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), +) + +// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers). +// Uses the shared memoMap so this runtime's Service instance is shared with AppRuntime +// — Effect callers that yield ModelsDev.Service see the same cache. +const promiseRuntime = ManagedRuntime.make(defaultLayer, { memoMap }) +export const get = () => promiseRuntime.runPromise(Service.use((s) => s.get())) +export const refresh = (force = false) => promiseRuntime.runPromise(Service.use((s) => s.refresh(force))) export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d9806d1391e..939110e044fb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const layer: Layer.Layer< Service, never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -1083,13 +1083,14 @@ const layer: Layer.Layer< const auth = yield* Auth.Service const env = yield* Env.Service const plugin = yield* Plugin.Service + const modelsDevSvc = yield* ModelsDev.Service const state = yield* InstanceState.make(() => Effect.gen(function* () { using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const modelsDev = yield* modelsDevSvc.get() const database = mapValues(modelsDev, fromModelsDevProvider) const providers: Record = {} as Record @@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index c8689eabab9a..f9df530a9269 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index cc67355901cb..8ff7bc31035d 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() => const svc = yield* Provider.Service const cfg = yield* Config.Service const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} From 3a8a2748f6a0eafda728815602ac7ff9ab2510b9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:29:10 -0400 Subject: [PATCH 2/6] refactor(models): use Flock.effect, makeRuntime, cachedInvalidateWithTTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings + a real bug: 1. Replace 'Effect.promise(() => Flock.withLock(... runPromise(fetchApi) ...))' with 'Effect.scoped(Effect.gen { Flock.effect(lockKey); fetchAndWrite })'. Flock.effect is the existing scoped acquire/release; the runPromise re-entry inside the Promise lock body was dropping the parent fiber's tracing span, scope, and interruption. 2. Replace hand-rolled 'ManagedRuntime.make(defaultLayer, { memoMap })' with the existing 'makeRuntime(Service, defaultLayer)' helper from src/effect/run-service.ts. Same pattern as bus, sync, instance-store, etc. 3. Use 'Effect.cachedInvalidateWithTTL(populate, Duration.infinity)' for single-flight cache + manual invalidation. Concurrent first-callers now share one populate; refresh() invalidates so the next get() re-populates. 4. Restore eager initial refresh — Schedule.fixed waits the duration before the first run, which would have lost the original 'void refresh()' on module load. Now: refresh().pipe(Effect.andThen(refresh().repeat(...))). 5. Simplify fresh() mtime check — fs.stat's info.mtime is always Option; dropped the dead Option.isOption guard. 6. Drop unnecessary Effect.fnUntraced on loadFromDisk/loadSnapshot (these are values, not traced effects). Replaced loadSnapshot's manual try/catch wrapper with Effect.tryPromise + Effect.catch. 7. Effect.orDie on populate — Service interface declares get returns Effect> (E = never); failures from HttpClient, FileSystem, JSON.parse must die rather than silently leak. Smoke + provider tests pass. --- packages/opencode/src/provider/models.ts | 102 +++++++++++------------ 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 13c06c8bec3e..2c05bf32fca4 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,13 +1,13 @@ import { Global } from "@opencode-ai/core/global" import path from "path" -import { Context, Duration, Effect, Layer, ManagedRuntime, Option, Schedule, Schema } from "effect" -import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Installation } from "../installation" import { Flag } from "@opencode-ai/core/flag/flag" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { makeRuntime } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ @@ -111,12 +111,10 @@ export const layer: Layer.Layer | undefined - const fresh = Effect.fnUntraced(function* () { const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!stat?.mtime) return false - const mtime = Option.isOption(stat.mtime) ? Number(Option.getOrElse(stat.mtime, () => new Date(0)).getTime()) : 0 + if (!stat) return false + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() return Date.now() - mtime < Duration.toMillis(ttl) }) @@ -129,67 +127,69 @@ export const layer: Layer.Layer Effect.succeed(undefined))) as Effect.Effect< - Record | undefined - > - }) + const loadFromDisk = fs + .readJson(Flag.OPENCODE_MODELS_PATH ?? filepath) + .pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) - const loadSnapshot = Effect.promise(async () => { - try { - // @ts-ignore — generated at build time, may not exist in dev - const m = await import("./models-snapshot.js") - return m.snapshot as Record | undefined - } catch { - return undefined - } + // Bundled snapshot fallback — present in built releases, missing in dev. + const loadSnapshot = Effect.tryPromise({ + // @ts-ignore — generated at build time, may not exist in dev + try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record | undefined), + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + + // Cross-process file lock — Flock is the right primitive (in-process + // semaphores can't coordinate concurrent opencode CLIs writing the same cache). + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + yield* fs.writeWithDirs(filepath, text) + return text }) - const populate = Effect.fn("ModelsDev.populate")(function* () { - const fromDisk = yield* loadFromDisk() + 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 {} - - // Cross-process file lock — Flock is the right primitive (in-process - // semaphores can't coordinate concurrent opencode CLIs writing the same cache). - return yield* Effect.promise(() => - Flock.withLock(lockKey, async () => { - const text = await Effect.runPromise(fetchApi()) - await Effect.runPromise(fs.writeWithDirs(filepath, text)) + return yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + const text = yield* fetchAndWrite() return JSON.parse(text) as Record }), ) - }) + }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) - const get = Effect.fn("ModelsDev.get")(function* () { - if (cached) return cached - cached = yield* populate() - return cached - }) + // Single-flight cache: concurrent first-call dedupes via the cached effect. + // refresh() invalidates so the next get() re-populates from disk. + const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) + + const get = (): Effect.Effect> => cachedGet const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { - if (!force && (yield* fresh())) { - cached = undefined - return - } - yield* Effect.promise(() => - Flock.withLock(lockKey, async () => { - const text = await Effect.runPromise(fetchApi()) - await Effect.runPromise(fs.writeWithDirs(filepath, text)) + if (!force && (yield* fresh())) return yield* invalidate + yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + if (!force && (yield* fresh())) return + yield* fetchAndWrite() }), ).pipe( Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), Effect.ignore, ) - cached = undefined + yield* invalidate }) if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")), Effect.ignore)) + // Eager initial refresh + 1 hour cadence (matches pre-Service behavior). + yield* Effect.forkScoped( + refresh().pipe(Effect.andThen(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")))), Effect.ignore), + ) } return Service.of({ get, refresh }) @@ -202,10 +202,10 @@ export const defaultLayer: Layer.Layer = layer.pipe( ) // Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers). -// Uses the shared memoMap so this runtime's Service instance is shared with AppRuntime -// — Effect callers that yield ModelsDev.Service see the same cache. -const promiseRuntime = ManagedRuntime.make(defaultLayer, { memoMap }) -export const get = () => promiseRuntime.runPromise(Service.use((s) => s.get())) -export const refresh = (force = false) => promiseRuntime.runPromise(Service.use((s) => s.refresh(force))) +// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one +// AppRuntime sees — Effect callers and Promise callers operate on the same cache. +const runtime = makeRuntime(Service, defaultLayer) +export const get = () => runtime.runPromise((s) => s.get()) +export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force)) export * as ModelsDev from "./models" From fdaf026eba9f3245ef40081df55248ab5ee12d13 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:47:55 -0400 Subject: [PATCH 3/6] fix(httpapi): provide ModelsDev service to instance API routes The new ModelsDev Service wasn't included in createRoutes layer mergeAll, so HttpApi handlers calling ModelsDev.Service.use() got an unhandled defect at runtime, surfacing as a bare 500. --- packages/opencode/src/server/routes/instance/httpapi/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 3ac0298c6be9..767bfc31db86 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { Question } from "@/question" @@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) { InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, MCP.defaultLayer, + ModelsDev.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, From 47cd26498cab68177487943ddb5adfc8869026e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:58:50 -0400 Subject: [PATCH 4/6] test(models): cover ModelsDev Service with mocked HttpClient Mocks HttpClient via HttpClient.makeWith and pre-populates the on-disk cache to drive every code path: disk-hit, disk-empty fallback, single-flight dedup, in-memory cache stickiness, refresh fetch, fresh/stale TTL skip, and HTTP-error swallow. Layer.fresh is required to defeat the process-global MemoMap so each test gets its own cachedInvalidateWithTTL state. --- .../opencode/test/provider/models.test.ts | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/opencode/test/provider/models.test.ts diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts new file mode 100644 index 000000000000..68f7f2b5d614 --- /dev/null +++ b/packages/opencode/test/provider/models.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { ModelsDev } from "../../src/provider/models" +import { it } from "../lib/effect" +import { rm, writeFile, utimes, mkdir } from "fs/promises" +import path from "path" + +// test/preload.ts sets OPENCODE_MODELS_PATH to a static fixture and leaves +// MODELS_FETCH enabled — both flags are captured at flag-module load time, +// which is too early for env mutation to take effect. Mutate the resolved Flag +// values directly; they're read inside Layer.effect when each test builds. +Flag.OPENCODE_DISABLE_MODELS_FETCH = true +Flag.OPENCODE_MODELS_PATH = undefined + +const cacheFile = path.join(Global.Path.cache, "models.json") + +const fixture: Record = { + acme: { + id: "acme", + name: "Acme", + env: ["ACME_API_KEY"], + models: { + "acme-1": { + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }, + }, + }, +} + +const fixture2: Record = { + beta: { + id: "beta", + name: "Beta", + env: ["BETA_API_KEY"], + models: { + "beta-1": { + id: "beta-1", + name: "Beta One", + release_date: "2026-02-01", + attachment: false, + reasoning: true, + temperature: false, + tool_call: false, + limit: { context: 64000, output: 4096 }, + }, + }, + }, +} + +interface MockState { + body: string + status: number + calls: Array<{ url: string }> +} + +const makeMockClient = (state: Ref.Ref): HttpClient.HttpClient => + HttpClient.makeWith( + Effect.fnUntraced(function* (requestEffect) { + const request = yield* requestEffect + yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] })) + const s = yield* Ref.get(state) + return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) + }), + Effect.succeed as HttpClient.HttpClient.Preprocess, + ) + +const buildLayer = (state: Ref.Ref) => + // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // and Effect.provide uses a process-global MemoMap by default — without fresh, + // every test would reuse the cachedInvalidateWithTTL state from the first run. + Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + Layer.provide(AppFileSystem.defaultLayer), + ) + +const writeCache = (data: object, mtimeMs?: number) => + Effect.promise(async () => { + await mkdir(Global.Path.cache, { recursive: true }) + await writeFile(cacheFile, JSON.stringify(data)) + if (mtimeMs !== undefined) { + const t = mtimeMs / 1000 + await utimes(cacheFile, t, t) + } + }) + +const provided = (state: Ref.Ref, eff: Effect.Effect) => + eff.pipe(Effect.provide(buildLayer(state))) + +beforeEach(async () => { + await rm(cacheFile, { force: true }) +}) + +afterAll(async () => { + await rm(cacheFile, { force: true }) +}) + +const initialState: MockState = { + body: JSON.stringify(fixture), + status: 200, + calls: [], +} + +describe("ModelsDev Service", () => { + it.live("get() returns providers from disk when cache file exists", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual(fixture) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() returns {} when disk empty and fetch disabled", () => + Effect.gen(function* () { + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual({}) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() is single-flight under concurrent calls", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const results = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + return yield* Effect.all( + [svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], + { concurrency: "unbounded" }, + ) + }), + ) + for (const result of results) expect(result).toEqual(fixture) + }), + ) + + it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const first = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const a = yield* svc.get() + // mutate disk between calls — cache should mask the change + yield* writeCache(fixture2) + const b = yield* svc.get() + return { a, b } + }), + ) + expect(first.a).toEqual(fixture) + expect(first.b).toEqual(fixture) + }), + ) + + it.live("refresh(true) fetches via HttpClient and updates the cache", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const before = yield* svc.get() + yield* svc.refresh(true) + const after = yield* svc.get() + return { before, after } + }), + ) + expect(result.before).toEqual(fixture) + expect(result.after).toEqual(fixture2) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(final.calls[0].url).toContain("/api.json") + }), + ) + + it.live("refresh(false) skips fetch when on-disk file is fresh", () => + Effect.gen(function* () { + // Fresh: mtime within the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + yield* provided( + state, + ModelsDev.Service.use((s) => s.refresh(false)), + ) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("refresh(false) fetches when on-disk file is stale", () => + Effect.gen(function* () { + // Stale: mtime 10 minutes ago, beyond the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const after = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(false) + return yield* svc.get() + }), + ) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(after).toEqual(fixture2) + }), + ) + + it.live("refresh swallows HTTP errors and leaves cache intact", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(true) + return yield* svc.get() + }), + ) + // refresh logged + ignored; the cache map is whatever loadFromDisk yields next. + // With the failing fetch the disk file is unchanged → still the original fixture. + expect(result).toEqual(fixture) + // withTransientReadRetry retries 5xx, so calls > 1 is expected. + const final = yield* Ref.get(state) + expect(final.calls.length).toBeGreaterThanOrEqual(1) + }), + ) +}) From c50d53fb4ddd0858f54d210b102ecf459fe89ec4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 13:02:28 -0400 Subject: [PATCH 5/6] refactor(models): tighten Service body per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Schedule.spaced (not fixed) so the periodic refresh waits between completions instead of firing twice on startup. - Pull JSON.parse out of the Flock-held scope — keeps the cross-process lock release-eager. - Only invalidate after a successful fetchAndWrite; the no-op fresh-skip and ignored-failure paths shouldn't force the next get() to re-read. - Drop comments narrating obvious behavior; fix a misplaced WHY comment. - Use HttpClient.make (matches mockHttpClient elsewhere) instead of makeWith with a Preprocess cast in the new tests. --- packages/opencode/src/provider/models.ts | 25 ++++++++----------- .../opencode/test/provider/models.test.ts | 14 ++++------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 2c05bf32fca4..3654f66c79ed 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -134,15 +134,13 @@ export const layer: Layer.Layer v as Record | undefined), ) - // Bundled snapshot fallback — present in built releases, missing in dev. + // Bundled at build time; absent in dev — `tryPromise` covers both. const loadSnapshot = Effect.tryPromise({ // @ts-ignore — generated at build time, may not exist in dev try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record | undefined), catch: () => undefined, }).pipe(Effect.catch(() => Effect.succeed(undefined))) - // Cross-process file lock — Flock is the right primitive (in-process - // semaphores can't coordinate concurrent opencode CLIs writing the same cache). const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() yield* fs.writeWithDirs(filepath, text) @@ -155,41 +153,40 @@ export const layer: Layer.Layer + return yield* fetchAndWrite() }), ) + return JSON.parse(text) as Record }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) - // Single-flight cache: concurrent first-call dedupes via the cached effect. - // refresh() invalidates so the next get() re-populates from disk. const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) const get = (): Effect.Effect> => cachedGet const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { - if (!force && (yield* fresh())) return yield* invalidate + if (!force && (yield* fresh())) return yield* Effect.scoped( Effect.gen(function* () { yield* Flock.effect(lockKey) + // Re-check under the lock: another process may have refreshed between + // our outer check and lock acquisition. if (!force && (yield* fresh())) return yield* fetchAndWrite() + yield* invalidate }), ).pipe( Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), Effect.ignore, ) - yield* invalidate }) if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - // Eager initial refresh + 1 hour cadence (matches pre-Service behavior). - yield* Effect.forkScoped( - refresh().pipe(Effect.andThen(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")))), Effect.ignore), - ) + // Schedule.spaced runs the effect once, then waits between completions. + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore)) } return Service.of({ get, refresh }) diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts index 68f7f2b5d614..6dad7783543f 100644 --- a/packages/opencode/test/provider/models.test.ts +++ b/packages/opencode/test/provider/models.test.ts @@ -1,6 +1,6 @@ import { describe, expect, beforeEach, afterAll } from "bun:test" import { Effect, Layer, Ref } from "effect" -import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" @@ -64,15 +64,13 @@ interface MockState { calls: Array<{ url: string }> } -const makeMockClient = (state: Ref.Ref): HttpClient.HttpClient => - HttpClient.makeWith( - Effect.fnUntraced(function* (requestEffect) { - const request = yield* requestEffect +const makeMockClient = (state: Ref.Ref) => + HttpClient.make((request) => + Effect.gen(function* () { yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] })) const s = yield* Ref.get(state) return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) }), - Effect.succeed as HttpClient.HttpClient.Preprocess, ) const buildLayer = (state: Ref.Ref) => @@ -244,10 +242,8 @@ describe("ModelsDev Service", () => { return yield* svc.get() }), ) - // refresh logged + ignored; the cache map is whatever loadFromDisk yields next. - // With the failing fetch the disk file is unchanged → still the original fixture. expect(result).toEqual(fixture) - // withTransientReadRetry retries 5xx, so calls > 1 is expected. + // withTransientReadRetry retries 5xx, so calls may be > 1. const final = yield* Ref.get(state) expect(final.calls.length).toBeGreaterThanOrEqual(1) }), From 4b7e42eb3bcbe2c7fba9e55632ee5ecf44c99d4b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 13:49:13 -0400 Subject: [PATCH 6/6] test(models): save/restore Flag mutations to keep them suite-local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutating Flag.OPENCODE_MODELS_PATH at module-load time leaked the change into every subsequent test file in the same bun process — Provider state init then read undefined, fell into the snapshot/fetch fallback, and on slower CI lanes timed out or returned empty providers (surfacing as the "no providers found" failure on Windows for the SDK no-reply parity test). Wrap both mutations in beforeAll/afterAll so they only apply while this suite runs. --- .../opencode/test/provider/models.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts index 6dad7783543f..feb5bb5893f2 100644 --- a/packages/opencode/test/provider/models.test.ts +++ b/packages/opencode/test/provider/models.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, beforeEach, afterAll } from "bun:test" +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -9,12 +9,21 @@ import { it } from "../lib/effect" import { rm, writeFile, utimes, mkdir } from "fs/promises" import path from "path" -// test/preload.ts sets OPENCODE_MODELS_PATH to a static fixture and leaves -// MODELS_FETCH enabled — both flags are captured at flag-module load time, -// which is too early for env mutation to take effect. Mutate the resolved Flag -// values directly; they're read inside Layer.effect when each test builds. -Flag.OPENCODE_DISABLE_MODELS_FETCH = true -Flag.OPENCODE_MODELS_PATH = undefined +// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can +// resolve providers without network. These tests need to drive the on-disk +// cache themselves and silence the eager refresh fork. Save/restore around +// the suite — never leak the mutation to subsequent test files in the same +// bun process. +const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH +const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH +beforeAll(() => { + Flag.OPENCODE_MODELS_PATH = undefined + Flag.OPENCODE_DISABLE_MODELS_FETCH = true +}) +afterAll(() => { + Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH + Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH +}) const cacheFile = path.join(Global.Path.cache, "models.json")