mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
fix(core): skip models.dev refresh event when the catalog is unchanged (#44282)
This commit is contained in:
@@ -541,6 +541,9 @@ const decodeCatalog = (text: string) =>
|
||||
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
// Digest of the raw body, persisted so refresh() can skip republishing a
|
||||
// byte-identical catalog. Optional for entries written before it existed.
|
||||
digest: Schema.optional(Schema.String),
|
||||
body: CatalogJson,
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
@@ -568,6 +571,10 @@ function cacheKey(source: string) {
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export function bodyDigest(text: string) {
|
||||
return new Bun.CryptoHasher("sha256").update(text).digest("hex")
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -600,16 +607,11 @@ export const layer = (options?: Options) =>
|
||||
return {
|
||||
catalog: cached.value.body as Record<string, SourceProvider>,
|
||||
updatedAt: cached.value.updatedAt,
|
||||
digest: cached.value.digest,
|
||||
}
|
||||
if (value !== undefined) yield* kv.remove(key)
|
||||
})
|
||||
|
||||
const fresh = Effect.fnUntraced(function* () {
|
||||
const cached = yield* loadFromCache()
|
||||
if (!cached) return false
|
||||
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
|
||||
})
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
|
||||
HttpClientRequest.setHeader("User-Agent", userAgent),
|
||||
@@ -630,19 +632,23 @@ export const layer = (options?: Options) =>
|
||||
// periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* decodeCatalog(text)
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
return catalog
|
||||
})
|
||||
|
||||
@@ -672,8 +678,15 @@ export const layer = (options?: Options) =>
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
const stored = yield* loadFromCache()
|
||||
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
|
||||
const text = yield* fetchApi()
|
||||
// models.dev rarely changes between polls; skip the cache write,
|
||||
// invalidation, and Refreshed event for a byte-identical body so
|
||||
// downstream catalog.updated listeners stay quiet.
|
||||
if (!force && stored?.digest === bodyDigest(text)) return
|
||||
yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
@@ -180,7 +181,7 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
|
||||
[ModelsDev.node, ModelsDev.configured(options)],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeMockKV(cache)],
|
||||
@@ -199,13 +200,16 @@ const makeFailingWriteKV = (cache: MockCache) =>
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
cache.values.set(cacheKey, { updatedAt, body: text })
|
||||
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
|
||||
|
||||
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
|
||||
writeCacheText(cache, JSON.stringify(data), updatedAt)
|
||||
|
||||
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||
eff.pipe(Effect.provide(buildLayer(state, cache)))
|
||||
const provided = <A, E>(
|
||||
state: Ref.Ref<MockState>,
|
||||
cache: MockCache,
|
||||
eff: Effect.Effect<A, E, ModelsDev.Service | Bus.Service | Scope.Scope>,
|
||||
) => eff.pipe(Effect.provide(buildLayer(state, cache)))
|
||||
|
||||
const initialState: MockState = {
|
||||
body: JSON.stringify(fixture),
|
||||
@@ -391,7 +395,20 @@ describe("ModelsDev Service", () => {
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
yield* svc.refresh(false)
|
||||
const bus = yield* Bus.Service
|
||||
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* svc.refresh(false)
|
||||
return yield* Fiber.join(fiber)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(refreshed.length).toBe(1)
|
||||
return yield* svc.get()
|
||||
}),
|
||||
)
|
||||
@@ -401,6 +418,72 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) stays quiet when the fetched body matches the cached digest", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
const seeded = structuredClone(cache.values.get(cacheKey))
|
||||
// The server serves a byte-identical body, so the refresh still hits
|
||||
// the network but must not rewrite the cache or publish Refreshed.
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* svc.refresh(false)
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(event._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(cache.values.get(cacheKey)).toEqual(seeded)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* svc.refresh(false)
|
||||
return yield* Fiber.join(fiber)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(refreshed.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
|
||||
Reference in New Issue
Block a user