mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:56:18 +00:00
Compare commits
1
Commits
v2
...
models-cache
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917dcd7004 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
"@opencode-ai/server": patch
|
||||
---
|
||||
|
||||
Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { ModelsDevCache } from "./models-dev/cache.js"
|
||||
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
@@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
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"
|
||||
|
||||
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
|
||||
@@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
|
||||
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
|
||||
// host many runtimes (Cloudflare colocates Durable Object instances), so
|
||||
// per-runtime decoding would multiply the cost.
|
||||
let bundledCache: readonly Snapshot[] | undefined
|
||||
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
|
||||
const bundledSnapshot = Effect.suspend(() =>
|
||||
bundledCache
|
||||
? Effect.succeed(bundledCache)
|
||||
: decodeCatalog(snapshotText).pipe(
|
||||
Effect.map((catalog) => {
|
||||
bundledCache = normalize(catalog)
|
||||
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
|
||||
return bundledCache
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export function bodyDigest(text: string) {
|
||||
return Hash.sha256(text)
|
||||
}
|
||||
@@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const app = yield* App.Metadata
|
||||
const kv = yield* KV.Service
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
|
||||
const source = options?.url || defaultSource
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = App.useragent(app)
|
||||
const key = cacheKey(source)
|
||||
const ttl = Duration.minutes(5)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const loadFromCache = Effect.fnUntraced(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
const cached = Schema.decodeUnknownOption(Cache)(value)
|
||||
if (Option.isSome(cached))
|
||||
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 state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
|
||||
@@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
})
|
||||
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
// Persistence only seeds a runtime. Refresh never reloads this seed over
|
||||
// a catalog that was successfully fetched but could not be saved.
|
||||
// The service owns initialization so cancelling a reader cannot cancel it.
|
||||
const initialized = yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const stored = options?.file
|
||||
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
|
||||
: yield* cache.read(source)
|
||||
if (!stored) return
|
||||
const data = normalize(yield* decodeCatalog(stored.body))
|
||||
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
|
||||
),
|
||||
)
|
||||
: Effect.undefined
|
||||
if (state.data) return
|
||||
if (options?.snapshot !== false) {
|
||||
Object.assign(state, yield* bundledSnapshot)
|
||||
return
|
||||
}
|
||||
if (!fetch) state.data = []
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
// The bundled snapshot is the boot-time floor for the catalog; the
|
||||
// periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
|
||||
|
||||
// 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 update = Effect.fn("ModelsDev.update")(function* (force = false) {
|
||||
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
|
||||
const digest = bodyDigest(text)
|
||||
if (!force && state.data && state.digest === digest) {
|
||||
state.checkedAt = Date.now()
|
||||
return state.data
|
||||
}
|
||||
const data = normalize(yield* decodeCatalog(text))
|
||||
Object.assign(state, { data, digest, checkedAt: Date.now() })
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
// Adopt and publish before attempting persistence. A missing or broken
|
||||
// cache must not prevent live updates, including in filesystem-less runtimes.
|
||||
if (!options?.file)
|
||||
yield* cache.write(source, text).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
return data
|
||||
})
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
return catalog
|
||||
const get = Effect.fn("ModelsDev.get")(function* () {
|
||||
yield* Fiber.join(initialized)
|
||||
if (state.data) return state.data
|
||||
return yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
return state.data ?? (yield* update())
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const fromFile = yield* loadFromFile
|
||||
if (fromFile) return normalize(fromFile)
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return bundled
|
||||
if (!fetch) return []
|
||||
const catalog = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const stored = options?.file ? undefined : yield* loadFromCache()
|
||||
if (stored) return stored.catalog
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
return normalize(catalog)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
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, {})
|
||||
yield* Fiber.join(initialized)
|
||||
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
|
||||
yield* update(force)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
Effect.orDie,
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -710,7 +689,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
|
||||
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export * as ModelsDevCache from "./cache.js"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, FileSystem, Layer, Option } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
export interface Entry {
|
||||
readonly body: string
|
||||
readonly updatedAt: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
|
||||
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDevCache") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.cache, "models-dev")
|
||||
|
||||
const read = Effect.fn("ModelsDevCache.read")(
|
||||
function* (source: string) {
|
||||
const file = path.join(directory, `${Hash.fast(source)}.json`)
|
||||
const body = yield* fs.readFileString(file)
|
||||
const info = yield* fs.stat(file)
|
||||
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
|
||||
},
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
|
||||
)
|
||||
|
||||
const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
|
||||
yield* fs.writeFileString(temporary, body)
|
||||
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
|
||||
}, Effect.scoped)
|
||||
|
||||
return Service.of({ read, write })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [LayerNodePlatform.filesystem, Global.node],
|
||||
})
|
||||
|
||||
export const disabledLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const source = "https://models.opencode.ai"
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([ModelsDevCache.node, LayerNodePlatform.filesystem, Global.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
it.live("returns undefined for a missing catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
expect(yield* cache.read(source)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("persists raw catalog bodies larger than 2 MB with the file mtime", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const body = ` {\n "payload": "${"x".repeat(2 * 1024 * 1024)}"\n}\n`
|
||||
const file = path.join(global.cache, "models-dev", `${Hash.fast(source)}.json`)
|
||||
const modified = new Date("2026-01-01T00:00:00Z")
|
||||
|
||||
yield* cache.write(source, body)
|
||||
expect(yield* fs.readFileString(file)).toBe(body)
|
||||
yield* fs.utimes(file, modified, modified)
|
||||
expect(yield* cache.read(source)).toEqual({ body, updatedAt: modified.getTime() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates catalogs by source including the default source", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const custom = "https://models.example.com"
|
||||
|
||||
yield* cache.write(source, "default catalog")
|
||||
expect(yield* cache.read(custom)).toBeUndefined()
|
||||
yield* cache.write(custom, "custom catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("default catalog")
|
||||
expect((yield* cache.read(custom))?.body).toBe("custom catalog")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("replaces an existing catalog without leaving temporary files", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
|
||||
yield* cache.write(source, "old catalog")
|
||||
yield* cache.write(source, "new catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("new catalog")
|
||||
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cleans up temporary files and preserves platform errors when replacement fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.cache, "models-dev")
|
||||
const file = path.join(directory, `${Hash.fast(source)}.json`)
|
||||
yield* fs.makeDirectory(file, { recursive: true })
|
||||
|
||||
const error = yield* cache.write(source, "new catalog").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("PlatformError")
|
||||
expect(yield* fs.readDirectory(directory)).toEqual([`${Hash.fast(source)}.json`])
|
||||
expect((yield* fs.stat(file)).type).toBe("Directory")
|
||||
expect((yield* cache.read(source).pipe(Effect.flip))._tag).toBe("PlatformError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps the old catalog readable and cleans up an interrupted replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const staged = yield* Deferred.make<string>()
|
||||
yield* cache.write(source, "old catalog")
|
||||
|
||||
// Pause only the commit; staging and cleanup still use the real filesystem.
|
||||
const writer = yield* ModelsDevCache.Service.pipe(
|
||||
Effect.flatMap((service) => service.write(source, "new catalog")),
|
||||
Effect.provide(Layer.fresh(ModelsDevCache.layer)),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
rename: (file) => Deferred.succeed(staged, file).pipe(Effect.andThen(Effect.never)),
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const temporary = yield* Deferred.await(staged)
|
||||
expect(yield* fs.readFileString(temporary)).toBe("new catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("old catalog")
|
||||
|
||||
yield* Fiber.interrupt(writer)
|
||||
expect((yield* cache.read(source))?.body).toBe("old catalog")
|
||||
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
|
||||
}),
|
||||
)
|
||||
@@ -1,18 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Deferred, 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 { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const cacheKey = "models-dev:catalog"
|
||||
const source = "https://models.opencode.ai"
|
||||
|
||||
test("normalizes permissive interleaved values to compatibility", () => {
|
||||
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
|
||||
@@ -166,41 +168,40 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||
)
|
||||
|
||||
interface MockCache {
|
||||
readonly values: Map<string, KV.Value>
|
||||
readonly values: Map<string, ModelsDevCache.Entry>
|
||||
}
|
||||
|
||||
const makeMockKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
const makeMockCache = (cache: MockCache) =>
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: (source) => Effect.sync(() => cache.values.get(source)),
|
||||
write: (source, body) =>
|
||||
Effect.sync(() => cache.values.set(source, { updatedAt: Date.now(), body })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
|
||||
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
const buildLayer = (
|
||||
state: Ref.Ref<MockState>,
|
||||
cache: MockCache,
|
||||
options: ModelsDev.Options = { fetch: false },
|
||||
persistence = makeMockCache(cache),
|
||||
) =>
|
||||
Layer.fresh(
|
||||
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)],
|
||||
[ModelsDevCache.node, persistence],
|
||||
]),
|
||||
)
|
||||
|
||||
// Mirrors production KV backends whose writes die as defects (e.g. Durable
|
||||
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
|
||||
const makeFailingWriteKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
const makeFailingWriteCache = (cache: MockCache) =>
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: (source) => Effect.sync(() => cache.values.get(source)),
|
||||
write: () => Effect.die(new Error("Cache write failed")),
|
||||
})
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
|
||||
cache.values.set(source, { updatedAt, body: text })
|
||||
|
||||
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
|
||||
writeCacheText(cache, JSON.stringify(data), updatedAt)
|
||||
@@ -218,7 +219,7 @@ const initialState: MockState = {
|
||||
}
|
||||
|
||||
describe("ModelsDev Service", () => {
|
||||
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
|
||||
it.live("get() returns normalized snapshots from the persisted cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
@@ -259,7 +260,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
|
||||
it.live("get() returns empty catalog when the cache, fetch, and bundled snapshot are unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -272,7 +273,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
|
||||
it.live("get() falls back to the bundled snapshot when the cache is empty and fetch is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -289,7 +290,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
|
||||
it.live("get() recovers from a corrupted cache by fetching a fresh catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCacheText(cache, "{")
|
||||
@@ -297,31 +298,247 @@ describe("ModelsDev Service", () => {
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() still populates the catalog when the KV cache write fails", () =>
|
||||
it.live("get() still populates the catalog when persistence fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
]),
|
||||
)
|
||||
const layer = buildLayer(state, cache, { fetch: true, snapshot: false }, makeFailingWriteCache(cache))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.has(cacheKey)).toBe(false)
|
||||
expect(cache.values.has(source)).toBe(false)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const seeded of [false, true]) {
|
||||
it.live(`refresh adopts and publishes the fetched catalog when persistence fails (seeded=${seeded})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
if (seeded) writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* models.get()).not.toEqual(fixture2Snapshot)
|
||||
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.andThen(() => models.get()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* Fiber.join(event)).toEqual(fixture2Snapshot)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
yield* models.refresh()
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: false }, makeFailingWriteCache(cache))))
|
||||
expect(cache.values.get(source)?.body).toBe(seeded ? JSON.stringify(fixture) : undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("a failed cache read falls back to the bundled snapshot without blocking refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
expect((yield* models.get()).length).toBeGreaterThan(0)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
cache,
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () => Effect.die(new Error("Cache read failed")),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh publishes the live catalog while its cache write is still pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const writing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
const event = yield* bus
|
||||
.subscribe(ModelsDev.Event.Refreshed)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
const refresh = yield* models.refresh(true).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(writing)
|
||||
yield* Fiber.join(event).pipe(Effect.timeout("1 second"))
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(refresh)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
cache,
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () => Effect.succeed(cache.values.get(source)),
|
||||
write: () => Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() can use the bundled snapshot while the initial background fetch is pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const reading = yield* Deferred.make<void>()
|
||||
const releaseRead = yield* Deferred.make<void>()
|
||||
const fetching = yield* Deferred.make<void>()
|
||||
const releaseFetch = yield* Deferred.make<void>()
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||
[
|
||||
ModelsDevCache.node,
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Deferred.succeed(reading, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseRead)),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
[
|
||||
LayerNodePlatform.httpClient,
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Deferred.succeed(fetching, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseFetch)),
|
||||
Effect.as(HttpClientResponse.fromWeb(request, new Response(JSON.stringify(fixture)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
yield* Deferred.await(reading)
|
||||
const get = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.succeed(releaseRead, undefined)
|
||||
yield* Deferred.await(fetching)
|
||||
expect((yield* Fiber.join(get).pipe(Effect.timeout("1 second"))).length).toBeGreaterThan(0)
|
||||
yield* Deferred.succeed(releaseFetch, undefined)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cancelling a reader during initialization does not poison later reads or refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
const reading = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const first = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(reading)
|
||||
yield* Fiber.interrupt(first)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
makeCache(),
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Deferred.succeed(reading, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({ body: JSON.stringify(fixture), updatedAt: Date.now() }),
|
||||
),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("custom source URLs do not read or overwrite the default source cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const result = yield* ModelsDev.Service.use((models) => models.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { url: "https://catalog.example", fetch: true, snapshot: false })),
|
||||
)
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
|
||||
expect(cache.values.get("https://catalog.example")?.body).toBe(JSON.stringify(fixture2))
|
||||
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://catalog.example/api.json")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("an explicit file remains authoritative and refresh rereads it without HTTP or cache access", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const file = path.join(dir.path, "catalog.json")
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture)))
|
||||
const state = yield* Ref.make(initialState)
|
||||
const cacheCalls: string[] = []
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture2)))
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
makeCache(),
|
||||
{ file, fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Effect.sync(() => {
|
||||
cacheCalls.push("read")
|
||||
return undefined
|
||||
}),
|
||||
write: () => Effect.sync(() => void cacheCalls.push("write")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls).toEqual([])
|
||||
expect(cacheCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
@@ -348,7 +565,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
|
||||
it.live("get() retains the live catalog instead of rereading persistence", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
@@ -387,7 +604,7 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
expect(result.before).toEqual(fixtureSnapshot)
|
||||
expect(result.after).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
@@ -395,7 +612,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
|
||||
it.live("refresh(false) skips fetch when the persisted catalog is fresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 1000)
|
||||
@@ -410,7 +627,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) fetches when the KV entry is stale", () =>
|
||||
it.live("refresh(false) fetches when the persisted catalog is stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
@@ -447,7 +664,7 @@ describe("ModelsDev Service", () => {
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
const seeded = structuredClone(cache.values.get(cacheKey))
|
||||
const seeded = structuredClone(cache.values.get(source))
|
||||
// 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)
|
||||
@@ -474,38 +691,24 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(cache.values.get(cacheKey)).toEqual(seeded)
|
||||
expect(cache.values.get(source)).toEqual(seeded)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
|
||||
it.live("concurrent refreshes share the freshness check even when the body is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
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)
|
||||
yield* Effect.all([svc.refresh(), svc.refresh(), svc.refresh()], { concurrency: "unbounded" })
|
||||
}),
|
||||
)
|
||||
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -529,4 +732,25 @@ describe("ModelsDev Service", () => {
|
||||
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const body of ["{", JSON.stringify({ broken: {} })]) {
|
||||
it.live(`refresh preserves the live and persisted catalog when the response is invalid: ${body}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body })
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const before = yield* models.get()
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toBe(before)
|
||||
}),
|
||||
)
|
||||
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.wor
|
||||
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
@@ -30,6 +31,7 @@ import type { ServerOptions } from "./options"
|
||||
* backs them; Snapshot and Vcs degrade to no-op results.
|
||||
* - Config is injected as a string (no filesystem); plugin discovery is
|
||||
* precompiled-only, and stdio MCP reports the same no-plane failure as Shell.
|
||||
* - The models.dev catalog is memory-only, with no local filesystem cache.
|
||||
*
|
||||
* Bundle with the `workerd` condition, e.g.
|
||||
* `bun build src/workerd.ts --conditions=workerd --target=node`
|
||||
@@ -81,6 +83,7 @@ export function replacements(options: Options): LayerNode.Replacements {
|
||||
[Vcs.node, vcsLayer],
|
||||
[FileSystem.node, fileSystemLayer],
|
||||
[FileSystemSearch.node, fileSystemSearchLayer],
|
||||
[ModelsDevCache.node, ModelsDevCache.disabledLayer],
|
||||
[Pty.node, ptyLayer],
|
||||
// Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm
|
||||
// install, or import of plugin code from disk.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerWorkerd } from "../src/workerd"
|
||||
@@ -31,3 +37,56 @@ it.live("boots the workerd profile over durable object storage", () =>
|
||||
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("refreshes a memory-only catalog without a local filesystem", () =>
|
||||
Effect.gen(function* () {
|
||||
const name = yield* Ref.make("Acme One")
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...ServerWorkerd.replacements({ storage: makeDurableObjectStorage() }),
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: false, snapshot: false })],
|
||||
[Global.node, Layer.succeed(Global.Service, Global.make())],
|
||||
[LayerNodePlatform.filesystem, FileSystem.layerNoop({})],
|
||||
[
|
||||
LayerNodePlatform.httpClient,
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({
|
||||
acme: {
|
||||
id: "acme",
|
||||
name: yield* Ref.get(name),
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
yield* cache.write("https://models.opencode.ai", "not persisted")
|
||||
expect(yield* cache.read("https://models.opencode.ai")).toBeUndefined()
|
||||
expect(yield* models.get()).toEqual([])
|
||||
|
||||
yield* models.refresh(true)
|
||||
expect((yield* models.get()).map((provider) => provider.info.name)).toEqual(["Acme One"])
|
||||
yield* Ref.set(name, "Acme Two")
|
||||
yield* models.refresh(true)
|
||||
expect((yield* models.get()).map((provider) => provider.info.name)).toEqual(["Acme Two"])
|
||||
expect(yield* cache.read("https://models.opencode.ai")).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.fresh(LayerNode.compile(LayerNode.group([ModelsDev.node, ModelsDevCache.node]), replacements)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user