fix(core): isolate models.dev auto refresh
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
parent
5455fed78e
commit
b684cc8746
5 changed files with 84 additions and 22 deletions
|
|
@ -115,7 +115,7 @@ declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: () => Effect.Effect<Record<string, Provider>>
|
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") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||||
|
|
@ -224,21 +224,26 @@ const layer = Layer.effect(
|
||||||
yield* invalidate
|
yield* invalidate
|
||||||
yield* events.publish(Event.Refreshed, {})
|
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 })
|
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 const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||||
|
|
||||||
export * as ModelsDev from "./models-dev"
|
export * as ModelsDev from "./models-dev"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Effect, Layer, Ref } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
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 { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
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
|
// 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
|
// 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
|
// the suite — never leak the mutation to subsequent test files in the same
|
||||||
// bun process.
|
// bun process.
|
||||||
const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
|
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) =>
|
const writeCacheText = (text: string, mtimeMs?: number) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
await mkdir(Global.Path.cache, { recursive: true })
|
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* () {
|
Effect.gen(function* () {
|
||||||
yield* writeCache(fixture)
|
yield* writeCache(fixture)
|
||||||
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
|
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
|
||||||
const result = yield* provided(
|
const result = yield* Effect.exit(
|
||||||
state,
|
provided(
|
||||||
Effect.gen(function* () {
|
state,
|
||||||
const svc = yield* ModelsDev.Service
|
Effect.gen(function* () {
|
||||||
yield* svc.refresh(true)
|
const svc = yield* ModelsDev.Service
|
||||||
return yield* svc.get()
|
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.
|
// retryTransient retries 5xx, so calls may be > 1.
|
||||||
const final = yield* Ref.get(state)
|
const final = yield* Ref.get(state)
|
||||||
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
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) {
|
handler: Effect.fn("Cli.models")(function* (args) {
|
||||||
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
|
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
|
||||||
if (args.refresh) {
|
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)
|
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 { AppNodeBuilderV1 } from "./app-node-builder-v1"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
|
||||||
export const AppLayer = AppNodeBuilderV1.build(
|
const app = AppNodeBuilderV1.build(
|
||||||
LayerNode.group([
|
LayerNode.group([
|
||||||
Npm.node,
|
Npm.node,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
|
|
@ -106,7 +106,13 @@ export const AppLayer = AppNodeBuilderV1.build(
|
||||||
ShareNext.node,
|
ShareNext.node,
|
||||||
SessionShare.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 })
|
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||||
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,7 @@ export function createRoutes(
|
||||||
HttpServer.layerServices,
|
HttpServer.layerServices,
|
||||||
]),
|
]),
|
||||||
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
||||||
|
Layer.provideMerge(ModelsDev.autoRefreshLayer.pipe(Layer.provide(AppNodeBuilderV1.build(app)))),
|
||||||
Layer.provideMerge(Observability.layer),
|
Layer.provideMerge(Observability.layer),
|
||||||
|
|
||||||
Layer.provide(sessionLocationLayer),
|
Layer.provide(sessionLocationLayer),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue