Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 9dfc3732ff fix(core): log background models.dev refresh failures at debug 2026-07-03 09:06:53 -04:00
2 changed files with 73 additions and 6 deletions
+17 -4
View File
@@ -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<Record<string, Provider>> => 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 })
+56 -2
View File
@@ -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<MockState>) =>
]),
)
const makeCaptureLoggerLayer = (entries: Array<{ message: string; logLevel: string }>) =>
Layer.mergeAll(
Logger.layer(
[
Logger.make<unknown, void>((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)
}),
)
})