diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index c2325cb57fda..76af5cb4ea87 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" @@ -212,7 +212,7 @@ const layer = Layer.effect( const get = (): Effect.Effect> => cachedGet - const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + const attempt = Effect.fn("ModelsDev.refresh.attempt")(function* (force = false) { if (!force && (yield* fresh())) return yield* Effect.scoped( Effect.gen(function* () { @@ -224,7 +224,11 @@ const layer = Layer.effect( yield* invalidate yield* events.publish(Event.Refreshed, {}) }), - ).pipe( + ) + }) + + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + yield* attempt(force).pipe( Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), Effect.ignore, ) @@ -232,7 +236,16 @@ const layer = Layer.effect( 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)) + // Keep best-effort background failures at Debug: before the file logger is + // installed, Effect's default logger writes to stdout, which corrupts the TUI. + yield* Effect.forkScoped( + attempt().pipe( + Effect.tapCause((cause) => Effect.logDebug("Failed to fetch models.dev", { cause: cause })), + Effect.ignore, + Effect.repeat(Schedule.spaced("60 minutes")), + Effect.ignore, + ), + ) } return Service.of({ get, refresh }) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8cda..06a34d9444a3 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,9 +1,8 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" -import { Effect, Layer, Ref } from "effect" +import { Effect, Layer, Logger, Ref, References } 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" @@ -97,6 +96,22 @@ const buildLayer = (state: Ref.Ref) => ]), ) +const makeCaptureLoggerLayer = (entries: Array<{ message: string; logLevel: string }>) => + Layer.mergeAll( + Logger.layer( + [ + Logger.make((options) => { + entries.push({ message: logMessage(options.message), logLevel: options.logLevel }) + }), + ], + { mergeWithExisting: false }, + ), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ) + +const logMessage = (message: unknown) => + Array.isArray(message) ? message.map((item) => String(item)).join(" ") : String(message) + const writeCacheText = (text: string, mtimeMs?: number) => Effect.promise(async () => { await mkdir(Global.Path.cache, { recursive: true }) @@ -287,4 +302,43 @@ describe("ModelsDev Service", () => { expect(final.calls.length).toBeGreaterThanOrEqual(1) }), ) + + it.live("background refresh logs fetch failures at Debug", () => + Effect.gen(function* () { + const entries: Array<{ message: string; logLevel: string }> = [] + const state = yield* Ref.make({ ...initialState, status: 404, body: "boom" }) + yield* Effect.acquireUseRelease( + Effect.sync(() => { + Flag.OPENCODE_DISABLE_MODELS_FETCH = false + }), + () => + Effect.gen(function* () { + yield* Layer.build(buildLayer(state)) + yield* Effect.sleep("1 second") + }).pipe(Effect.scoped, Effect.provide(makeCaptureLoggerLayer(entries))), + () => + Effect.sync(() => { + Flag.OPENCODE_DISABLE_MODELS_FETCH = true + }), + ) + const failures = entries.filter((entry) => entry.message.includes("Failed to fetch models.dev")) + expect(failures.some((entry) => entry.logLevel === "Debug")).toBe(true) + expect(failures.some((entry) => entry.logLevel === "Error")).toBe(false) + }), + ) + + it.live("manual refresh logs fetch failures at Error", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const entries: Array<{ message: string; logLevel: string }> = [] + const state = yield* Ref.make({ ...initialState, status: 404, body: "boom" }) + yield* ModelsDev.Service.use((s) => s.refresh(true)).pipe( + Effect.provide(buildLayer(state)), + Effect.provide(makeCaptureLoggerLayer(entries)), + ) + const failures = entries.filter((entry) => entry.message.includes("Failed to fetch models.dev")) + expect(failures.some((entry) => entry.logLevel === "Error")).toBe(true) + expect(failures.some((entry) => entry.logLevel === "Debug")).toBe(false) + }), + ) })