fix(core): isolate models.dev auto refresh

Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-07-03 04:44:48 +00:00
parent 5455fed78e
commit b684cc8746
5 changed files with 85 additions and 23 deletions
+14 -9
View File
@@ -115,7 +115,7 @@ declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<Record<string, Provider>>
readonly refresh: (force?: boolean) => Effect.Effect<void>
readonly refresh: (force?: boolean) => Effect.Effect<void, unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
@@ -224,21 +224,26 @@ const layer = Layer.effect(
yield* invalidate
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: 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 autoRefreshLayer = Layer.effectDiscard(
Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return
if (process.argv.includes("--get-yargs-completions")) return
const svc = yield* Service
const refresh = svc.refresh().pipe(
Effect.catchCause((cause) => Effect.logWarning("Failed to refresh models.dev catalog", { cause })),
)
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh.pipe(Effect.repeat(Schedule.spaced("60 minutes"))))
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export * as ModelsDev from "./models-dev"
+59 -11
View File
@@ -3,7 +3,6 @@ import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -13,7 +12,7 @@ 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
// cache themselves and control background refresh. 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
@@ -97,6 +96,9 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
]),
)
const buildAutoRefreshLayer = (state: Ref.Ref<MockState>) =>
ModelsDev.autoRefreshLayer.pipe(Layer.provideMerge(buildLayer(state)))
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
@@ -269,22 +271,68 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
it.live("refresh surfaces 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()
}),
const result = yield* Effect.exit(
provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
return yield* svc.get()
}),
),
)
expect(result).toEqual(fixture)
expect(result._tag).toBe("Failure")
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture))
// retryTransient retries 5xx, so calls may be > 1.
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
it.live("constructing the service does not start a background refresh", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => Layer.build(buildLayer(state)).pipe(Effect.andThen(Effect.sleep("20 millis"))),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)
it.live("autoRefreshLayer starts best-effort background refresh", () =>
Effect.gen(function* () {
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() =>
Effect.gen(function* () {
const context = yield* Layer.build(buildAutoRefreshLayer(state))
yield* Effect.sleep("700 millis")
return yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
}),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
expect(result).toEqual(fixture)
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
})
+3 -1
View File
@@ -26,7 +26,9 @@ export const ModelsCommand = effectCmd({
handler: Effect.fn("Cli.models")(function* (args) {
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
if (args.refresh) {
yield* ModelsDev.Service.use((s) => s.refresh(true))
yield* ModelsDev.Service.use((s) => s.refresh(true)).pipe(
Effect.catch((error) => fail(`Failed to refresh models cache: ${String(error)}`)),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}
+8 -2
View File
@@ -55,7 +55,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilderV1 } from "./app-node-builder-v1"
import { SessionProjector } from "@opencode-ai/core/session/projector"
export const AppLayer = AppNodeBuilderV1.build(
const app = AppNodeBuilderV1.build(
LayerNode.group([
Npm.node,
FSUtil.node,
@@ -106,7 +106,13 @@ export const AppLayer = AppNodeBuilderV1.build(
ShareNext.node,
SessionShare.node,
]),
).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer))
)
export const AppLayer = ModelsDev.autoRefreshLayer.pipe(
Layer.provideMerge(app),
Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)),
Layer.provideMerge(Observability.layer),
)
const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
@@ -292,6 +292,7 @@ export function createRoutes(
HttpServer.layerServices,
]),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(ModelsDev.autoRefreshLayer.pipe(Layer.provide(AppNodeBuilderV1.build(app)))),
Layer.provideMerge(Observability.layer),
Layer.provide(sessionLocationLayer),