mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 10:45:33 -04:00
fix(core): isolate models.dev auto refresh
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user