From 178c90697ec14aa02b31cd86dc6431f0c66868cf Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 01:15:07 +0530 Subject: [PATCH] fix(models): fall back when catalog refresh stalls Ported from upstream anomalyco/opencode#36003. --- packages/core/src/models-dev.ts | 48 ++++++++++++++++++++----------- packages/core/test/models.test.ts | 48 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index c2325cb57fda..56ba42f1fa6d 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,6 +1,6 @@ import path from "path" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Global } from "./global" import { Flag } from "./flag/flag" @@ -109,6 +109,10 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type +const Providers = Schema.Record(Schema.String, Provider) +const decodeProvidersEffect = Schema.decodeUnknownEffect(Providers) +const decodeProviders = Schema.decodeUnknownSync(Providers) + export const Event = ModelsDev.Event declare const OPENCODE_MODELS_DEV: Record | undefined @@ -141,6 +145,7 @@ const layer = Layer.effect( source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, ) const ttl = Duration.minutes(5) + const populateFetchTimeout = Duration.seconds(2) const lockKey = `models-dev:${filepath}` const fresh = Effect.fnUntraced(function* () { @@ -160,23 +165,25 @@ const layer = Layer.effect( }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( - Effect.catch((error) => { - if ( - Flag.OPENCODE_MODELS_PATH === undefined && - error._tag === "FileSystemError" && - error.method === "readJson" - ) { + Effect.flatMap((v) => decodeProvidersEffect(v)), + Effect.catch((_error) => { + if (Flag.OPENCODE_MODELS_PATH === undefined) { return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined)) } return Effect.succeed(undefined) }), - Effect.map((v) => v as Record | undefined), ) const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, ) + const loadCached = Effect.fn("ModelsDev.loadCached")(function* () { + const fromDisk = yield* loadFromDisk + if (fromDisk) return fromDisk + return yield* loadSnapshot + }) + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp` @@ -192,20 +199,29 @@ const layer = Layer.effect( return text }) - 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 fetchFromRemote = Effect.gen(function* () { const text = yield* Effect.scoped( Effect.gen(function* () { yield* Flock.effect(lockKey) return yield* fetchAndWrite() }), ) - return JSON.parse(text) as Record + return decodeProviders(JSON.parse(text)) + }) + + const populate = Effect.gen(function* () { + const cached = yield* loadCached() + if (cached) return cached + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + return yield* fetchFromRemote.pipe( + Effect.timeout(populateFetchTimeout), + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("Falling back to cached models.dev catalog", { cause }) + return (yield* loadCached()) ?? {} + }), + ), + ) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8cda..e2096f156cd7 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -3,9 +3,9 @@ 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 { Hash } from "@opencode-ai/core/util/hash" import { ModelsDev } from "@opencode-ai/core/models-dev" import { it } from "./lib/effect" import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" @@ -28,6 +28,7 @@ afterAll(() => { }) const cacheFile = path.join(Global.Path.cache, "models.json") +const lockDir = path.join(Global.Path.state, "locks", `${Hash.fast(`models-dev:${cacheFile}`)}.lock`) const fixture: Record = { acme: { @@ -72,6 +73,7 @@ const fixture2: Record = { interface MockState { body: string status: number + stall?: boolean calls: Array<{ url: string; userAgent: string | null }> } @@ -83,6 +85,7 @@ const makeMockClient = (state: Ref.Ref) => calls: [...s.calls, { url: request.url, userAgent: request.headers["user-agent"] ?? null }], })) const s = yield* Ref.get(state) + if (s.stall) return yield* Effect.never return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) }), ) @@ -114,10 +117,12 @@ const provided = (state: Ref.Ref, eff: Effect.Effect { await rm(cacheFile, { force: true }) + await rm(lockDir, { recursive: true, force: true }) }) afterAll(async () => { await rm(cacheFile, { force: true }) + await rm(lockDir, { recursive: true, force: true }) }) const initialState: MockState = { @@ -154,6 +159,47 @@ describe("ModelsDev Service", () => { }), ) + it.live( + "get() falls back when startup fetch stalls and refresh can later replace it", + Effect.gen(function* () { + const state = yield* Ref.make({ ...initialState, stall: true }) + const context = yield* Layer.build(buildLayer(state)) + const result = yield* Effect.acquireUseRelease( + Effect.sync(() => { + Flag.OPENCODE_DISABLE_MODELS_FETCH = false + }), + () => + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const started = Date.now() + const fallback = yield* svc.get() + const elapsed = Date.now() - started + + yield* Ref.update(state, (s) => ({ + ...s, + body: JSON.stringify(fixture2), + status: 200, + stall: false, + })) + yield* svc.refresh(true) + const refreshed = yield* svc.get() + return { elapsed, fallback, refreshed } + }).pipe(Effect.provide(context)), + () => + Effect.sync(() => { + Flag.OPENCODE_DISABLE_MODELS_FETCH = true + }), + ) + + expect(result.elapsed).toBeLessThan(5000) + expect(result.fallback).toEqual({}) + expect(result.refreshed).toEqual(fixture2) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(2) + }), + 10_000, + ) + it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () => Effect.gen(function* () { yield* writeCacheText("{")