Просмотр исходного кода

feat(models): effectify ModelsDev as Service (#25434)

Kit Langton 4 месяцев назад
Родитель
Сommit
f8738c9002

+ 1 - 2
packages/opencode/src/cli/cmd/models.ts

@@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({
       }),
       }),
   handler: Effect.fn("Cli.models")(function* (args) {
   handler: Effect.fn("Cli.models")(function* (args) {
     if (args.refresh) {
     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)
       UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
     }
     }
 
 

+ 2 - 0
packages/opencode/src/effect/app-runtime.ts

@@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher"
 import { Storage } from "@/storage/storage"
 import { Storage } from "@/storage/storage"
 import { Snapshot } from "@/snapshot"
 import { Snapshot } from "@/snapshot"
 import { Plugin } from "@/plugin"
 import { Plugin } from "@/plugin"
+import { ModelsDev } from "@/provider/models"
 import { Provider } from "@/provider/provider"
 import { Provider } from "@/provider/provider"
 import { ProviderAuth } from "@/provider/auth"
 import { ProviderAuth } from "@/provider/auth"
 import { Agent } from "@/agent/agent"
 import { Agent } from "@/agent/agent"
@@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll(
   Storage.defaultLayer,
   Storage.defaultLayer,
   Snapshot.defaultLayer,
   Snapshot.defaultLayer,
   Plugin.defaultLayer,
   Plugin.defaultLayer,
+  ModelsDev.defaultLayer,
   Provider.defaultLayer,
   Provider.defaultLayer,
   ProviderAuth.defaultLayer,
   ProviderAuth.defaultLayer,
   Agent.defaultLayer,
   Agent.defaultLayer,

+ 110 - 78
packages/opencode/src/provider/models.ts

@@ -1,25 +1,14 @@
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
-import * as Log from "@opencode-ai/core/util/log"
 import path from "path"
 import path from "path"
-import { Schema } from "effect"
+import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { Installation } from "../installation"
 import { Installation } from "../installation"
 import { Flag } from "@opencode-ai/core/flag/flag"
 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 { Flock } from "@opencode-ai/core/util/flock"
 import { Hash } from "@opencode-ai/core/util/hash"
 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 { makeRuntime } from "@/effect/run-service"
+import { withTransientReadRetry } from "@/util/effect-http-client"
 
 
 const Cost = Schema.Struct({
 const Cost = Schema.Struct({
   input: Schema.Finite,
   input: Schema.Finite,
@@ -101,76 +90,119 @@ export const Provider = Schema.Struct({
 
 
 export type Provider = Schema.Schema.Type<typeof Provider>
 export type Provider = Schema.Schema.Type<typeof Provider>
 
 
-function url() {
-  return Flag.OPENCODE_MODELS_URL || "https://models.dev"
+export interface Interface {
+  readonly get: () => Effect.Effect<Record<string, Provider>>
+  readonly refresh: (force?: boolean) => Effect.Effect<void>
 }
 }
 
 
-function fresh() {
-  return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl
-}
+export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
 
 
-function skip(force: boolean) {
-  return !force && fresh()
-}
+export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClient.HttpClient> = 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<string, unknown>)
-    .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)
-  })
-})
+    const fresh = Effect.fnUntraced(function* () {
+      const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+      if (!stat) return false
+      const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
+      return Date.now() - mtime < Duration.toMillis(ttl)
+    })
 
 
-export async function get() {
-  const result = await Data()
-  return result as Record<string, Provider>
-}
+    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"),
+      )
+    })
 
 
-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 loadFromDisk = fs
+      .readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)
+      .pipe(
+        Effect.catch(() => Effect.succeed(undefined)),
+        Effect.map((v) => v as Record<string, Provider> | undefined),
+      )
+
+    // 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<string, Provider> | undefined),
+      catch: () => undefined,
+    }).pipe(Effect.catch(() => Effect.succeed(undefined)))
+
+    const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
+      const text = yield* fetchApi()
+      yield* fs.writeWithDirs(filepath, text)
+      return text
     })
     })
-  })
-}
 
 
-if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
-  void refresh()
-  setInterval(
-    async () => {
-      await refresh()
-    },
-    60 * 1000 * 60,
-  ).unref()
-}
+    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 text = yield* Effect.scoped(
+        Effect.gen(function* () {
+          yield* Flock.effect(lockKey)
+          return yield* fetchAndWrite()
+        }),
+      )
+      return JSON.parse(text) as Record<string, Provider>
+    }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
+
+    const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
+
+    const get = (): Effect.Effect<Record<string, Provider>> => cachedGet
+
+    const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
+      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,
+      )
+    })
+
+    if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
+      // 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 })
+  }),
+)
+
+export const defaultLayer: Layer.Layer<Service> = layer.pipe(
+  Layer.provide(FetchHttpClient.layer),
+  Layer.provide(AppFileSystem.defaultLayer),
+)
+
+// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers).
+// 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"
 export * as ModelsDev from "./models"

+ 4 - 2
packages/opencode/src/provider/provider.ts

@@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
 const layer: Layer.Layer<
 const layer: Layer.Layer<
   Service,
   Service,
   never,
   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(
 > = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -1083,13 +1083,14 @@ const layer: Layer.Layer<
     const auth = yield* Auth.Service
     const auth = yield* Auth.Service
     const env = yield* Env.Service
     const env = yield* Env.Service
     const plugin = yield* Plugin.Service
     const plugin = yield* Plugin.Service
+    const modelsDevSvc = yield* ModelsDev.Service
 
 
     const state = yield* InstanceState.make<State>(() =>
     const state = yield* InstanceState.make<State>(() =>
       Effect.gen(function* () {
       Effect.gen(function* () {
         using _ = log.time("state")
         using _ = log.time("state")
         const bridge = yield* EffectBridge.make()
         const bridge = yield* EffectBridge.make()
         const cfg = yield* config.get()
         const cfg = yield* config.get()
-        const modelsDev = yield* Effect.promise(() => ModelsDev.get())
+        const modelsDev = yield* modelsDevSvc.get()
         const database = mapValues(modelsDev, fromModelsDevProvider)
         const database = mapValues(modelsDev, fromModelsDevProvider)
 
 
         const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
         const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
@@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() =>
     Layer.provide(Config.defaultLayer),
     Layer.provide(Config.defaultLayer),
     Layer.provide(Auth.defaultLayer),
     Layer.provide(Auth.defaultLayer),
     Layer.provide(Plugin.defaultLayer),
     Layer.provide(Plugin.defaultLayer),
+    Layer.provide(ModelsDev.defaultLayer),
   ),
   ),
 )
 )
 
 

+ 1 - 1
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 list = Effect.fn("ProviderHttpApi.list")(function* () {
       const config = yield* cfg.get()
       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 disabled = new Set(config.disabled_providers ?? [])
       const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
       const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
       const filtered: Record<string, (typeof all)[string]> = {}
       const filtered: Record<string, (typeof all)[string]> = {}

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store"
 import { Plugin } from "@/plugin"
 import { Plugin } from "@/plugin"
 import { Project } from "@/project/project"
 import { Project } from "@/project/project"
 import { ProviderAuth } from "@/provider/auth"
 import { ProviderAuth } from "@/provider/auth"
+import { ModelsDev } from "@/provider/models"
 import { Provider } from "@/provider/provider"
 import { Provider } from "@/provider/provider"
 import { Pty } from "@/pty"
 import { Pty } from "@/pty"
 import { Question } from "@/question"
 import { Question } from "@/question"
@@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
       InstanceBootstrap.defaultLayer,
       InstanceBootstrap.defaultLayer,
       InstanceStore.defaultLayer,
       InstanceStore.defaultLayer,
       MCP.defaultLayer,
       MCP.defaultLayer,
+      ModelsDev.defaultLayer,
       Permission.defaultLayer,
       Permission.defaultLayer,
       Plugin.defaultLayer,
       Plugin.defaultLayer,
       Project.defaultLayer,
       Project.defaultLayer,

+ 1 - 1
packages/opencode/src/server/routes/instance/provider.ts

@@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() =>
           const svc = yield* Provider.Service
           const svc = yield* Provider.Service
           const cfg = yield* Config.Service
           const cfg = yield* Config.Service
           const config = yield* cfg.get()
           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 disabled = new Set(config.disabled_providers ?? [])
           const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
           const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
           const filtered: Record<string, (typeof all)[string]> = {}
           const filtered: Record<string, (typeof all)[string]> = {}

+ 260 - 0
packages/opencode/test/provider/models.test.ts

@@ -0,0 +1,260 @@
+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"
+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 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")
+
+const fixture: Record<string, ModelsDev.Provider> = {
+  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<string, ModelsDev.Provider> = {
+  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<MockState>) =>
+  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 }))
+    }),
+  )
+
+const buildLayer = (state: Ref.Ref<MockState>) =>
+  // 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 = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
+  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()
+        }),
+      )
+      expect(result).toEqual(fixture)
+      // withTransientReadRetry retries 5xx, so calls may be > 1.
+      const final = yield* Ref.get(state)
+      expect(final.calls.length).toBeGreaterThanOrEqual(1)
+    }),
+  )
+})